Repository navigation
Security
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.
Do not open a public issue. Use GitHub's private vulnerability reporting:
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.
| Version | Supported |
|---|---|
main (HEAD) |
Yes |
| Latest release | Yes |
| Anything earlier | No |
Fixes are not backported. Upgrading is the fix; see Upgrading.
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/getreturns an allow-list: core presentation keys plus anything namedpublic_*. Keys whose names look like credentials (password,secret,token,api_key,private_keyand 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.
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_SECRETis 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 setADMIN_PASSWORDbefore first boot. - Signing in with the seeded password is refused (
403 SEED_PASSWORD_REFUSED). Runnpm run reset-password.ALLOW_SEED_PASSWORD=1overrides this for a trusted private deployment.
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-srcis 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_WASMand others. Seesrc/lib/csp-config.ts. Set them beforenpm run build. -
astro devsends no CSP. Test CSP against the built server.
- Session cookies are
HttpOnlyandSameSite=Lax, andSecurein production.COOKIE_SECURE=1or0forces the flag either way. - A same-origin page gets the
astrobaas_csrfcookie when it loads and sends it back asX-CSRF-Tokenon 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_ORIGINSmay 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_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, andGET /api/health/deepreportscors_origins: warn. -
Originis 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.
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.
- 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-passwordsigns the account out everywhere.
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).
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-methodsand/api/couponscount asproducts. - 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_iplets a server-side storefront name the shopper's address inX-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.
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 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=keepturns 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>, externalhrefs and CSSurl()/@importare all dropped. Only the sanitised bytes are stored. Served SVGs get their ownContent-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 defaultprivate-uploads/beside the database), notpublic/uploads. No static handler serves that directory. Files are downloaded only by signed-in staff via/api/forms/file/{id}, always as an attachment withnosniff. - Stored HTML (posts, product descriptions, imports) passes through an allow-list sanitiser. Plugin content filters are sanitised again when the page is rendered.
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.
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.
- Generate a strong
AUTH_SECRET(openssl rand -hex 32). - Set
ADMIN_PASSWORDbefore 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), nevernpm run dev. - Put it behind HTTPS via a reverse proxy.
- Set
TRUST_PROXY=1if, and only if, it sits behind a proxy you control. - Set
HSTS_MAX_AGEonce TLS covers every request (HSTS_INCLUDE_SUBDOMAINS=1andHSTS_PRELOAD=1are optional). It is off by default on purpose. - List your storefront origins in
CORS_ORIGINS, not*. - Scope API keys, and mark
forward_client_iponly on server-side keys. - Set
RATE_LIMIT_STORE=libsqlif you run more than one replica. - Put the database,
uploads/andprivate-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=devafter dependency bumps and check whether anything new is reachable at runtime (sharp and sanitize-html are).
- Configuration: every environment variable
- Deployment: reverse proxy, systemd and Docker
- Headless-and-REST-API: keys, CORS and CSRF from a storefront
- Plugins: why code plugins are compiled in rather than uploaded
- FAQ-and-Troubleshooting
- SECURITY.md
AstroBaaS is free software under GPL-3.0-or-later. This wiki describes the latest release; the repository and its CHANGELOG are the final word. Found a mistake here? Open an issue.
Getting started
Using the CMS
Building with it
Extending
Commerce and email
Operating
Project