# Security policy ## Supported versions AstroBaaS is **pre-alpha**. `0.1.0` is the first public release. Security fixes land on `main` and go out in the next release. Only the current `main` and the most recent release are supported; nothing is backported to an earlier version, so upgrading is the fix. | Version | Supported | | ---------------- | --------- | | `main` (HEAD) | ✅ | | Latest release | ✅ | | Anything earlier | ❌ | ## Reporting a vulnerability If you find a security issue, **please do not open a public issue.** Instead, use GitHub's private vulnerability reporting: [**Report a vulnerability**](https://github.com/operator888/astrobaas/security/advisories/new). Please include: - A description of the issue and impact. - A minimal reproduction (URL, payload, expected vs actual behaviour). - Whether you intend to publish a CVE/blog post — we'd like to coordinate. We aim to acknowledge within **3 business days**. After that: this is one maintainer, so we will not promise you a fix by a date we might miss. What we do promise is a **status update at least once a week** until the issue is fixed or we have agreed with you that it will not be, and that high-severity issues are worked before anything else. We will credit you in the changelog unless you ask us not to. ## Threat model and known limitations AstroBaaS is designed for **single-node, self-hosted, low-traffic** use. It is not yet hardened for multi-tenant or high-traffic deployments. In particular: - **No HTTPS termination.** Run AstroBaaS behind a reverse proxy (Caddy, Nginx, Traefik) that provides TLS. Cookies are `HttpOnly` and `SameSite=Lax`, and in production (`NODE_ENV=production`) also `Secure`, so you must serve over HTTPS. Force the flag on/off with `COOKIE_SECURE=1/0`. - **Rate limiting is per-process by default.** The zero-config limiter keeps per-IP counters in memory, so a multi-replica deployment under-counts. For multi-node, set `RATE_LIMIT_STORE=libsql` (with a libSQL `DATABASE_URL`) to share counters across instances via atomic SQL — the API, login, and password-reset throttles all use it. The server logs which store is active at startup and warns if a libSQL DB is configured but the shared store wasn't enabled. You can still put an edge limiter (Cloudflare, nginx-limit-req) in front as defence in depth. A full in-memory store (50,000 keys per map) evicts expired entries first, then the live ones closest to expiring — it never clears itself, so flooding it with fresh keys cannot reset a login throttle or re-open a spent magic link or captcha proof. The libSQL store sweeps expired rows for **all** keys at most once a minute, so the `rate_limits` table no longer grows with every address that ever called. - **Per-IP limits count an IPv6 /64 as one caller** and an IPv4-mapped IPv6 address as its IPv4 address. `locals.ip` — and therefore every per-IP budget, order-risk hash and audit entry — holds that grouped identity, so an IPv6 client's audit line shows its /64, not the full address. - **Expensive routes have their own per-IP budgets** (checkout, quote, payment start, search), below the general 60/min; payment webhooks are taken out of the anonymous budget and given a generous one. Defaults and env overrides are in INTEGRATION.md. - **Trusted client-IP forwarding is a trust grant.** An API key an admin marks `forward_client_ip` may name, in `X-AstroBaaS-Client-IP`, the shopper a request is for; that address then drives the per-IP route budgets, order risk and the audit trail. The header is ignored for anonymous callers, cookie sessions and unmarked keys. A LEAKED forwarding key can therefore rotate the address it is counted against — it stays inside its own per-key budget (6000/min by default), and revoking the key ends it. Mark only keys that live on a server. - **Sign-in throttles cannot be used to lock an owner out.** Three counters, fifteen-minute windows: - 10 attempts per address + email — a hard `429`, as before; - 30 *failed* sign-ins per address across all emails (credential stuffing) — a hard `429` for that address only (`LOGIN_IP_FAILURE_LIMIT`); - 5 failed sign-ins per account from **any** address — not a lock: from then on that account's password is only checked together with a solved proof-of-work (`403 POW_REQUIRED`, challenge included; `LOGIN_ACCOUNT_POW_AFTER`). The admin sign-in page solves it in the background, so the owner signs in as usual. Unknown emails are counted the same way, so the counter reveals nothing about which accounts exist. The two-factor step allows 10 code attempts per account per window (`TWOFA_ATTEMPT_LIMIT`), on both the pending-cookie and the password+code paths. Reaching it requires the password, so only someone who already has the password can spend that budget. - **Signing out revokes the token on the server.** Each session token carries an id; signing out records it on the account until the token would have expired, and the middleware refuses it — across restarts and replicas, on every driver. Other devices stay signed in. Two fallbacks sign the account out **everywhere** instead (bumping `session_version`): a token issued before this release (it has no id — this lasts at most the 24-hour token lifetime after upgrading), and an account with more than 50 unexpired signed-out tokens. `npm run reset-password` now signs the account out everywhere too, as the web reset always did. - **Password hashing runs off the event loop.** PBKDF2 (120,000 iterations, SHA-256) is computed on libuv's thread pool, so a burst of sign-in or forgot-password requests no longer stalls every other request in the process. The output is unchanged; existing hashes verify. - **A write without a length is refused.** The per-route body ceilings are read from `Content-Length` before a handler buffers anything, so an `/api` write that carries `Transfer-Encoding` and no `Content-Length` gets `411`. Browsers always send the length. nginx with its default `proxy_request_buffering on` (the shipped config) buffers a chunked client body and re-sends it with `Content-Length`. **Caddy streams request bodies by default**, so a streaming API client behind Caddy is refused; buffer in the client, or at the proxy (`request_buffers` in `reverse_proxy` — check on your Caddy version that it re-sends the length). - **`CORS_ORIGINS=*` widens who may embed your checkout.** Credentials are never allowed cross-origin, so no data is exposed, but cookie-less checkout, payment-start, contact and newsletter posts are accepted from any site's pages. The server warns at startup and the deep health check reports `cors_origins: warn`. `Origin` is set by browsers and is **not** a boundary against non-browser clients; those are bounded by the rate limits and input validation, not by the allow-list. - **The CSRF cookie is set on HTML pages only** (and on a JSON sign-in), never on `/api` responses or non-HTML files, so public API responses carry no `Set-Cookie` and can be cached by a CDN. - **Maintenance mode leaves payment webhooks open.** A provider confirming a payment made just before the window is answered by the handler (which still verifies the signature), not by a `503`. - **Background work runs in one process per database.** The scheduler (email sends, order cancellation, off-site backups) and the schema migrations take a lease first (`leases` table on libSQL, a lock file beside a lowdb database). `SCHEDULER_LEASE=0` removes that guard and must only be used with a single process. Lease files and rows hold a hostname and a pid, nothing else. - **CSP is hash-based (no `'unsafe-inline'` for scripts).** The production build emits a Content-Security-Policy response header via Astro's built-in CSP: `script-src 'self'` plus the SHA-256 hash of every bundled script (including Astro's own island-hydration scripts) — `'unsafe-inline'` is gone, closing the main inline-XSS foothold. The app authors no inline styles either (theme tokens are served from `/theme.css`; other dynamic styling uses CSSOM), so `style-src` is hash-based too. CSP source allow-lists (e.g. a CDN for 3D assets) are now **build-time** env knobs read by `astro.config.ts` (`CSP_IMG_SRC`, `CSP_SCRIPT_SRC`, `CSP_ALLOW_WASM`, …; see `src/lib/csp-config.ts`) — set them before `npm run build`. Note: the CSP header is emitted by the **build**, so `astro dev` does not send it; test CSP against the built server. - **SVG uploads are sanitized, not refused.** SVG is the one image format that is also a program, so it is handled apart from the raster pipeline: it never goes near sharp, and what was uploaded is never what is stored. The file is parsed and rebuilt through an allow-list (`src/lib/media/svg-sanitize.ts`, the same `sanitize-html` engine that guards rich text, in XML mode), so only known-inert elements and attributes survive re-serialization. `