Skip to content

Security

Theodor Dimitriou edited this page Sep 24, 2026 · 2 revisions

AstroBaaS's security model: how to report a vulnerability, what the threat model covers, and the controls that ship (CSP, CSRF, CORS, rate limits, two-factor sign-in, API key scopes, SSRF and SVG guards, private uploads, the audit log and GDPR requests). It ends with a hardening checklist for operators putting an install on the internet.

AstroBaaS is pre-alpha. It has had an adversarial self-review with regression tests, but no independent audit, pentest or bug bounty. Read SECURITY.md before you expose an install.

Reporting a vulnerability

Do not open a public issue. Use GitHub's private vulnerability reporting:

Report a vulnerability

Please include:

  • a description of the issue and its impact;
  • a minimal reproduction (URL, payload, expected and actual behaviour);
  • whether you plan to publish a CVE or write-up, so disclosure can be coordinated.

The project aims to acknowledge reports within 3 business days and to send a status update at least once a week until the issue is fixed or you agree it will not be. High-severity issues are worked on first. Reporters are credited in the changelog unless they ask not to be.

Supported versions

Version Supported
main (HEAD) Yes
Latest release Yes
Anything earlier No

Fixes are not backported. Upgrading is the fix; see Upgrading.

Threat model

AstroBaaS is designed for single-node, self-hosted, low-traffic use, with one install per site.

  • Not multi-tenant. The storage layer does not know who is asking. Visibility rules are enforced in middleware and route handlers, mainly in src/lib/visibility.ts. There is no database row-level security. If several unrelated businesses need isolation, run separate instances.
  • No TLS termination. Run it behind a reverse proxy (Caddy, nginx, Traefik) that provides HTTPS.
  • Content visibility. Anonymous visitors and viewers see published content only. Authors also see their own drafts, and editors and admins see all drafts. A record you may not see answers 404, not 403.
  • Settings are public only by opt-in. GET /api/settings/get returns an allow-list: core presentation keys plus anything named public_*. Keys whose names look like credentials (password, secret, token, api_key, private_key and similar) are never returned to anyone, admins included, only as <key>__is_set: true.
  • Secrets live in the environment. Payment keys, SMTP passwords and backup bucket credentials are read from env vars only, never from the settings table.

What is not a vulnerability: using admin@local / admin on a development install; an open /admin in development with AUTH_SECRET unset; bypassing CSRF with the user's own cooperation or a browser extension.

Default credentials in production

On a production build (what npm run build produces; NODE_ENV does not need to be set, and NODE_ENV=production applies the same rules to a dev server):

  • A missing, short (under 16 characters) or placeholder AUTH_SECRET is refused, and nobody can sign in until it is replaced.
  • A fresh install generates a random admin password (printed once to stdout) instead of seeding admin, unless you set ADMIN_PASSWORD before first boot.
  • Signing in with the seeded password is refused (403 SEED_PASSWORD_REFUSED). Run npm run reset-password. ALLOW_SEED_PASSWORD=1 overrides this for a trusted private deployment.

Content-Security-Policy

The production build sends a hash-based CSP:

  • script-src 'self' plus the SHA-256 hash of every bundled script. There is no 'unsafe-inline' for scripts.
  • style-src is hash-based too. Theme tokens are served from /theme.css, and plugin CSS is served from a route.
  • A pasted <iframe> is discarded. Embeds (YouTube, Vimeo, OpenStreetMap) render as a consent-gated placeholder.
  • Extra sources (for example a CDN for 3D assets) are build-time env vars: CSP_IMG_SRC, CSP_SCRIPT_SRC, CSP_ALLOW_WASM and others. See src/lib/csp-config.ts. Set them before npm run build.
  • astro dev sends no CSP. Test CSP against the built server.

Cookies and CSRF

  • Session cookies are HttpOnly and SameSite=Lax, and Secure in production. COOKIE_SECURE=1 or 0 forces the flag either way.
  • A same-origin page gets the astrobaas_csrf cookie when it loads and sends it back as X-CSRF-Token on writes. A JSON sign-in (POST /api/auth/login) also sets it.
  • API responses never set cookies, so a CDN can cache public GETs.
  • Bearer-key requests are CSRF-exempt. A key is a header, not something a browser attaches by itself.
  • Cookie-less requests from an origin in CORS_ORIGINS may place orders, start payments, and post the contact, newsletter and public forms (including file uploads) without the token. A request carrying a session cookie always needs it.

CORS

CORS_ORIGINS="https://shop.example.com http://localhost:3000"
  • Credentials are never allowed cross-origin, so no page can read a signed-in admin's data through CORS.
  • Do not use CORS_ORIGINS=*. The list also decides which websites may embed your checkout and forms from a visitor's browser. * lets any site do that. Nothing is taken from the visitor, but you can no longer say "only our storefront embeds our checkout". The server warns at startup, and GET /api/health/deep reports cors_origins: warn.
  • Origin is set by browsers and cannot be forged by page JavaScript, but a non-browser client can send anything. Those callers are limited by rate limits, input bounds and server-side pricing, not by the allow-list.
  • Error answers (401, 403, 411, 413, 429 and maintenance 503) carry CORS headers, so your code sees the real status. Only an unexpected 500 can arrive without them.

Rate limits and sign-in throttling

Every /api response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. A 429 adds Retry-After.

Caller Budget per minute Env
Anonymous, per address 60 RATE_LIMIT_PER_MIN
API key, per key 6000 RATE_LIMIT_API_KEY_PER_MIN
Signed-in staff, per person 1800 STAFF_RATE_LIMIT_PER_MIN
POST /api/orders 10 per address RATE_LIMIT_CHECKOUT_PER_MIN
POST /api/orders/quote 30 per address RATE_LIMIT_QUOTE_PER_MIN
POST /api/payments/start 10 per address RATE_LIMIT_PAYMENT_START_PER_MIN
Search 30 per address RATE_LIMIT_SEARCH_PER_MIN
Payment webhooks 600 per address RATE_LIMIT_WEBHOOK_PER_MIN

IPv6 callers are counted per /64. Counters are kept in memory for each process by default. With more than one replica, set RATE_LIMIT_STORE=libsql so replicas share counters.

Sign-in throttles, each over a 15-minute window:

Counter Limit Effect Env
Per address + email 10 attempts Hard 429 not configurable
Failed sign-ins per address, all emails 30 Hard 429 for that address LOGIN_IP_FAILURE_LIMIT
Failed sign-ins per account, any address 5 Not a lockout: the password is then checked only with a solved proof-of-work (403 POW_REQUIRED) LOGIN_ACCOUNT_POW_AFTER
Two-factor code attempts per account 10 Hard refusal TWOFA_ATTEMPT_LIMIT

The admin sign-in page solves the proof-of-work in the background, so the real owner signs in as usual and cannot be locked out by an attacker. Unknown emails are counted the same way, so the counters do not reveal which accounts exist.

Only set TRUST_PROXY=1 behind a proxy you control. Without it, every client behind a proxy looks like one address.

Proof-of-work can also be switched on for checkout and magic-link sign-in (Settings → Anti-spam check). It is off by default.

Sessions and passwords

  • Passwords are hashed with PBKDF2 (SHA-256, 120,000 iterations), off the main event loop.
  • Signing out revokes that session token on the server, across restarts and replicas. npm run reset-password signs the account out everywhere.

Two-factor authentication

Turn it on in Profile → Two-factor authentication. It needs a working email channel first, because email is the recovery path if you lose your authenticator.

  • It uses TOTP (any authenticator app).
  • When you enable it, you get 10 one-time backup codes. They are shown once and stored only as hashes.
  • Turning 2FA off needs a current code or a backup code.
  • API and SDK clients can send the password and code together. The browser flow uses a short-lived pending cookie (5 minutes).

API keys and scopes

Mint keys in Admin → API Keys or with POST /api/keys.

  • A key has a role and optional scopes of the form <resource>:<read|write|*> or *.
  • Scoped resources: posts, content, media, products, orders, customers, messages. /api/commerce/*, /api/shipping-methods and /api/coupons count as products.
  • A key with scopes is deny-by-default outside them. A key with no scopes is limited only by its role. Scope your keys.
  • Reads need :read; any other method needs :write.
  • Keys can have an expires_at.
  • forward_client_ip lets a server-side storefront name the shopper's address in X-AstroBaaS-Client-IP. Mark only keys that live on a server. A leaked forwarding key can choose which address its requests count against.

See src/lib/api-key-scopes.ts and Headless-and-REST-API.

Outbound requests (SSRF)

Webhook targets that name an internal host are refused. That covers localhost, .local, .internal, RFC 1918 ranges, link-local and cloud metadata (169.254.169.254), IPv6 loopback and ULA, and CGNAT. The guard does not resolve DNS, so a public name that resolves to a private address (DNS rebinding) is out of scope. WEBHOOK_ALLOW_PRIVATE=1 turns the guard off for trusted internal use.

PayPal's certificate URL must be on *.paypal.com. Only S3 backup endpoints on https are accepted, unless BACKUP_S3_ALLOW_HTTP=1 is set.

Uploads

  • Uploads are checked by magic bytes before decoding. Raster images are re-encoded with sharp, with a pixel limit. EXIF (GPS, device serial) is stripped by default (MEDIA_ORIGINAL_EXIF=keep turns this off).
  • SVG is sanitised, not refused. It is parsed and rebuilt through an allow-list. Scripts, event handlers, <foreignObject>, <image>, feImage, <animate>, <set>, <a>, external hrefs and CSS url()/@import are all dropped. Only the sanitised bytes are stored. Served SVGs get their own Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'.
  • Private uploads. Files attached to submissions of a public form go to PRIVATE_UPLOADS_DIR (by default private-uploads/ beside the database), not public/uploads. No static handler serves that directory. Files are downloaded only by signed-in staff via /api/forms/file/{id}, always as an attachment with nosniff.
  • Stored HTML (posts, product descriptions, imports) passes through an allow-list sanitiser. Plugin content filters are sanitised again when the page is rendered.

Audit log

Security-relevant actions are recorded: sign-ins and failures, refused seed passwords, payment rejections and refunds, tax overrides, prescription deletions, GDPR requests and more. Read it at Admin → Audit Log or through /api/audit. Plugins can record their own events with recordAudit from astrobaas/core.

GDPR data requests

Admin → Data requests (/admin/privacy, admin-only), or the API:

GET  /api/privacy/subject?email=...           # everything held about that address
POST /api/privacy/subject { "email": "...", "confirm": "ERASE" }
  • Access (Art. 15) collects what the install holds about the address.
  • Erasure (Art. 17) deletes the customer record, contact messages, newsletter subscription and form submissions.
  • Orders are pseudonymised, not deleted. Totals, lines, dates and tax stay for statutory retention; identifying fields are replaced. This is not full anonymisation, because a payment reference can still be looked up at the provider.
  • A receipt link stops working for an erased order.

Production hardening checklist

  • Generate a strong AUTH_SECRET (openssl rand -hex 32).
  • Set ADMIN_PASSWORD before first boot, or save the random password printed once at startup.
  • Enable two-factor on every admin account (Profile → Two-factor authentication), and store the backup codes.
  • Serve a production build (npm run build), never npm run dev.
  • Put it behind HTTPS via a reverse proxy.
  • Set TRUST_PROXY=1 if, and only if, it sits behind a proxy you control.
  • Set HSTS_MAX_AGE once TLS covers every request (HSTS_INCLUDE_SUBDOMAINS=1 and HSTS_PRELOAD=1 are optional). It is off by default on purpose.
  • List your storefront origins in CORS_ORIGINS, not *.
  • Scope API keys, and mark forward_client_ip only on server-side keys.
  • Set RATE_LIMIT_STORE=libsql if you run more than one replica.
  • Put the database, uploads/ and private-uploads/ on persistent storage that is backed up off the host. See Backups-and-Storage.
  • Set HEALTH_TOKEN (32+ characters) if a monitor reads /api/health/deep, and alert on its warnings.
  • Review the CSP and add any CDN origins with the CSP_* build-time variables.
  • Configure a real mail transport, so password resets and 2FA recovery do not go to the server log. See Email.
  • Run npm audit --omit=dev after dependency bumps and check whether anything new is reachable at runtime (sharp and sanitize-html are).

See also

Clone this wiki locally