AstroBaaS

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.

VersionSupported
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 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.

  • 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.

  • 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; set WEBHOOK_ALLOW_PRIVATE=1 to 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/get is an unauthenticated endpoint (decoupled storefronts read the site title from it), but settings are a schemaless key/value bucket — POST /api/settings/update accepts any identifier-shaped key. It therefore returns an allow-list, not the table: core presentation keys plus anything named public_*.

    Credential-shaped keys are withheld from every role, staff included. A key whose name matches password, secret, token, api_key, private_key and 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. publishable and public_key are 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_key is withheld by default.

    (This endpoint once returned the whole table to anonymous callers, and later still returned credentials to any staff session — an editor could read smtp_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 audit findings are in the Astro build/dev toolchainvite, esbuild, postcss, js-yaml, svgo — pulled in transitively by astro. Those are scoped to the dev server, the build step, or astro check, and several are Windows-only; the built dist/server/entry.mjs does not run Vite or esbuild. They surface under npm audit --omit=dev only 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:

    1. sharp (image decoding) IS runtime-reachable. POST /api/media/upload feeds 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 with failOn: 'truncated', and SVG is refused outright. Astro bundles its own nested sharp for astro:assets; we do not use astro:assets, so that copy is not on a request path.
    2. sanitize-html IS 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 in action, 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 — and tests/sanitize.test.mjs now 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' in script-src, a surviving javascript: URI would not execute.
    3. Astro’s own XSS advisories (View Transitions, transition:* directives, spread attributes in renderHTMLElement) 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=dev and 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, admin is not a default — it is a published credential.

  • Exposing /admin without auth in a development environment with AUTH_SECRET unset. Set NODE_ENV=production to make AUTH_SECRET mandatory 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:

rolepublishedown draftsothers’ drafts
anonymous / vieweryesno
authoryesyesno
editor / adminyesyesyes

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_PASSWORD before first boot, or captured the random password printed once to stdout. With NODE_ENV=production and no ADMIN_PASSWORD, AstroBaaS generates one rather than seeding the well-known admin — 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=1 overrides it for a trusted private deployment.
  • Enabled two-factor auth on admin accounts (Profile → Two-factor).
  • Set NODE_ENV=production.
  • Set HSTS_MAX_AGE once TLS is genuinely in front of every request (HSTS_INCLUDE_SUBDOMAINS=1, HSTS_PRELOAD=1 optional). 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=libsql if running more than one replica.
  • Put it behind HTTPS via a reverse proxy.
  • Mounted db.json and public/uploads/ to persistent volumes that are backed up off-host.
  • Set TRUST_PROXY=1 if (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 the CSP_* build-time env vars.