Run it
Security
Generated from SECURITY.md in the AstroBaaS repository. The repository is the source of truth; this page is a copy of it.
Supported versions
AstroBaaS is pre-alpha. Only the latest commit on main is supported.
Pinned releases will be tagged once the project reaches 0.1.0.
| Version | Supported |
|---|---|
main (HEAD) | ✅ |
| Anything else | ❌ |
Reporting a vulnerability
If you find a security issue, please do not open a public issue. Instead, email the maintainer privately: Report a vulnerability. 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 and ship a fix or mitigation within 14 days for high-severity issues. 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
HttpOnlyandSameSite=Lax, and in production (NODE_ENV=production) alsoSecure, so you must serve over HTTPS. Force the flag on/off withCOOKIE_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 libSQLDATABASE_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. -
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), sostyle-srcis hash-based too. CSP source allow-lists (e.g. a CDN for 3D assets) are now build-time env knobs read byastro.config.ts(CSP_IMG_SRC,CSP_SCRIPT_SRC,CSP_ALLOW_WASM, …; seesrc/lib/csp-config.ts) — set them beforenpm run build. Note: the CSP header is emitted by the build, soastro devdoes not send it; test CSP against the built server. -
Webhook SSRF guard is name/literal-based, not DNS-resolving. Registering a webhook target that resolves to a private IP is refused when the URL names an internal host (localhost, RFC-1918, link-local/cloud-metadata
169.254.169.254, IPv6 ULA/loopback, CGNAT). It does not resolve DNS, so a public hostname that resolves to a private address (DNS rebinding) is out of scope; setWEBHOOK_ALLOW_PRIVATE=1to disable the guard for trusted internal use. -
Backup export/import is lowdb-only. On the libSQL/Turso drivers the built-in JSON backup endpoints refuse to run (they’d read/write the wrong store); back up the database with your DB tooling instead.
-
Settings are public only by explicit opt-in.
GET /api/settings/getis an unauthenticated endpoint (decoupled storefronts read the site title from it), but settings are a schemaless key/value bucket —POST /api/settings/updateaccepts any identifier-shaped key. It therefore returns an allow-list, not the table: core presentation keys plus anything namedpublic_*.Credential-shaped keys are withheld from every role, staff included. A key whose name matches
password,secret,token,api_key,private_keyand similar is never returned as a value — only as<key>__is_set: true, so the admin can manage a credential it can never read back.publishableandpublic_keyare exempt, because a Stripe publishable key is meant to be public and withholding it would break checkout.This is deliberately a rule about the shape of the name rather than a list of known keys: a list is something someone must remember to update, and its failure mode is silent disclosure. An unreviewed plugin’s
foo_api_keyis withheld by default.(This endpoint once returned the whole table to anonymous callers, and later still returned credentials to any staff session — an
editorcould readsmtp_password. Both are fixed, and the smoke suite asserts on all three storage drivers that a non-public key is invisible anonymously and that a credential-shaped key is withheld even from an admin.) -
Known dependency advisories. Most open
npm auditfindings are in the Astro build/dev toolchain —vite,esbuild,postcss,js-yaml,svgo— pulled in transitively byastro. Those are scoped to the dev server, the build step, orastro check, and several are Windows-only; the builtdist/server/entry.mjsdoes not run Vite or esbuild. They surface undernpm audit --omit=devonly because Astro declares Vite as a runtime dependency.Not everything is build-only, and we do not claim otherwise. Three categories need judgement, and we re-check them on every dependency bump:
sharp(image decoding) IS runtime-reachable.POST /api/media/uploadfeeds uploaded bytes to sharp/libvips to re-encode images, so a decoder CVE is reachable by any account with a content-producing role. We therefore track sharp directly and keep it patched — currently sharp 0.35.3 / libvips 8.18.3, which resolves the GHSA-f88m-g3jw-g9cj libvips CVEs. Uploads are additionally magic-byte sniffed before decoding, decoded withfailOn: 'truncated', and SVG is refused outright. Astro bundles its own nested sharp forastro:assets; we do not useastro:assets, so that copy is not on a request path.sanitize-htmlIS runtime-reachable. It sanitizes every stored HTML field — post bodies, product descriptions, imported WordPress/WooCommerce content — so a bypass there is stored XSS. GHSA-vccv-cmxp-4j9h (javascript:URIs surviving inaction,formaction,data,poster,background) affected<=2.17.4; we run 2.17.6. The bypass was never reachable with our configuration anyway — the allow-list permits none of those attributes, and none of the tags that carry them — andtests/sanitize.test.mjsnow pins the advisory’s own vectors so widening the allow-list fails loudly. Note the hash-based CSP is a third layer: with no'unsafe-inline'inscript-src, a survivingjavascript:URI would not execute.- Astro’s own XSS advisories (View Transitions,
transition:*directives, spread attributes inrenderHTMLElement) are render-path issues, not build-only. They do not apply here because the codebase uses none of those features — verified by grep, not assumed. If you add View Transitions or spread attributes to a fork, re-evaluate and upgrade Astro first.
Advisory counts shift as the registry publishes new CVEs; re-run
npm audit --omit=devand check whether anything new sits on a request path.
What is not a vulnerability
-
Using the development seed credentials (
admin@local/admin) on a development install. Zero-config local dev is the point of the lowdb driver.This is no longer merely “warned about” in production: with
NODE_ENV=production, a fresh install generates a random admin password instead of seeding the known one, and login refuses the seeded password outright. Once this repo is public,adminis not a default — it is a published credential. -
Exposing
/adminwithout auth in a development environment withAUTH_SECRETunset. SetNODE_ENV=productionto makeAUTH_SECRETmandatory at startup. -
Bypassing CSRF using a browser extension or with the user’s explicit cooperation. CSRF protections defend against cross-origin attackers, not same-origin tooling.
Who can read what
Authorization is enforced in middleware and route handlers over a storage layer
that is identity-blind: getPosts() takes no caller, and the relational
driver runs SELECT data FROM posts with no predicate. That is a deliberate
design for a single-tenant CMS, and it has one failure mode worth stating
plainly: where database-enforced row-level security makes a forgotten check
return nothing, this design makes a forgotten check return everything.
AstroBaaS does not implement row-level security, and no document here should
be read as claiming it does. What it has instead is one module,
src/lib/visibility.ts, that decides content visibility, so the rule is written
once and reviewed once:
| role | published | own drafts | others’ drafts |
|---|---|---|---|
anonymous / viewer | yes | — | no |
author | yes | yes | no |
editor / admin | yes | yes | yes |
Deny-by-default: an unrecognised role sees published content only, so a role added to the union without being considered here loses access rather than gaining it. A record you may not see returns 404, not 403 — distinguishing “exists but forbidden” from “does not exist” confirms the slug, which is what withholding it is for.
Applied at: GET /api/posts, GET /api/posts/{ref}, /og/{slug}.png,
/admin/posts, /admin/posts/{id}/edit. Snapshot bodies in
GET /api/content/changes are restricted to the editorial roles for the same
reason.
If you are deploying multi-tenant — several unrelated businesses on one instance — this is not the isolation you want. Run separate instances.
Hardening checklist for operators
Before exposing AstroBaaS to the public internet:
- Generated a strong
AUTH_SECRET(openssl rand -hex 32). - Set
ADMIN_PASSWORDbefore first boot, or captured the random password printed once to stdout. WithNODE_ENV=productionand noADMIN_PASSWORD, AstroBaaS generates one rather than seeding the well-knownadmin— because in a public repo a default credential is a published credential. Login also refuses the seeded password in production (SEED_PASSWORD_REFUSED);ALLOW_SEED_PASSWORD=1overrides it for a trusted private deployment. - Enabled two-factor auth on admin accounts (Profile → Two-factor).
- Set
NODE_ENV=production. - Set
HSTS_MAX_AGEonce TLS is genuinely in front of every request (HSTS_INCLUDE_SUBDOMAINS=1,HSTS_PRELOAD=1optional). It is off by default on purpose: sent from a host that is not fully HTTPS it locks visitors out for the whole max-age with no client-side undo, and a self-hoster mid-migration must not be bricked by a CMS being helpful. - Set
RATE_LIMIT_STORE=libsqlif running more than one replica. - Put it behind HTTPS via a reverse proxy.
- Mounted
db.jsonandpublic/uploads/to persistent volumes that are backed up off-host. - Set
TRUST_PROXY=1if (and only if) you’re behind a proxy you control. - Reviewed the Content-Security-Policy (
src/lib/csp-config.ts, emitted by Astro at build) and added any CDN/asset origins your frontend needs via theCSP_*build-time env vars.