DocsSecurity

Running it

Security

How Polaris CMS protects the admin and the API: login rate limiting, stateless sessions, hashed API keys, a strict Content Security Policy and safe content handling.

The admin login#

  • One admin, from ADMIN_USERNAME and ADMIN_PASSWORD. Without both, the admin refuses to start a session (503).
  • Constant-time comparison. The username and the password are both hashed and compared with timingSafeEqual, and both are always checked, so timing doesn't reveal which one was wrong.
  • Rate limit. 8 login attempts per IP address per 15 minutes, then 429 Too many attempts. A successful login resets the counter. The counter is kept in memory: it is friction against brute force, not a wall, so use a long random password. The IP is the first address in X-Forwarded-For, which your proxy must set.
  • Every admin URL shows the login form until the session is valid, and every admin response is no-store and noindex, nofollow.

Sessions#

The session cookie studio_session is httpOnly, SameSite=Lax, Secure in production and lasts 30 days. Its value is an HMAC-SHA256 derived from the admin username and password plus STUDIO_SESSION_SECRET; nothing is stored on the server. That makes sign-out-everywhere simple: change the password or rotate the session secret and every existing cookie stops matching.

API keys#

  • A key is a prefix plus 40 random hex characters (160 bits), shown once at creation.
  • Only its SHA-256 hash is stored, with a short prefix for display. A database dump doesn't reveal usable keys.
  • Keys are accepted in Authorization: Bearer or x-api-key. A revoked key fails at once; each key records when it was last used.
  • Key management is session-only. /api/cms/keys refuses API keys, so a key can't create, list or revoke keys. In the kits, comment moderation is session-only too.

Content Security Policy#

The newest version runs the whole site, the admin included, under a strict CSP with no relaxation:

default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:;
frame-ancestors 'none'; upgrade-insecure-requests

That works because nothing is inlined:

  • The admin is not built from framework islands (they inline a loader script). Each admin page ships its data as a JSON data block and one bundled module renders the React view. React sets styles through the CSSOM, which style-src doesn't block.
  • The theme is applied before paint by an external script file.
  • The Markdown renderer outputs classes only: code highlighting, table alignment and chart blocks have no style attributes.
  • The build fails if any static page contains an inline script, an inline <style> or a style="" attribute.

The headers are set by nginx on every response, static and proxied: the CSP plus HSTS, X-Content-Type-Options: nosniff, Referrer-Policy, Permissions-Policy, Cross-Origin-Opener-Policy and X-Frame-Options.

The kit versions ship a CSP that allows 'unsafe-inline' (and the Turnstile and analytics hosts); tightening it there means reworking how their admin pages load.

Content safety#

  • Raw HTML in Markdown is dropped, so text from an agent or a pasted draft can't inject scripts, iframes or tracking pixels.
  • Uploads are decoded, not trusted. A declared non-image type is refused; a missing type is decided by what sharp can actually decode. Every image is re-encoded to WebP, which also strips metadata. Uploads are capped at 12 MB.
  • SVG is never served as SVG. In the newest version an uploaded SVG is rasterised to lossless WebP, so it can't carry script onto your domain. The kits don't accept SVG.
  • Media responses carry X-Content-Type-Options: nosniff and the stored type.
  • External links in posts get rel="noopener noreferrer".

Drafts are never public#

Every public query (pages, listings, RSS, the sitemap, the list API action) only returns posts that are published, or scheduled and due. A draft has no public URL, and a scheduled post has none until its time.

Requests from the browser#

CMS writes take JSON bodies (or multipart for uploads) and the session cookie is SameSite=Lax, so another site can't make the admin's browser post to the API with the cookie. Astro's form-origin check is turned off so that agents can upload without an Origin header; every write still needs a valid session or key.

Reporting a problem#

Found a security issue? Write to hello@scentro.pl before posting it anywhere public.

Open source under MIT. Public release on GitHub and npm in preparation. Roadmap.