Skip to content

Security

To report a vulnerability, use GitHub’s private vulnerability reporting for the repository; see SECURITY.md. This page is the reference for what the CMS assumes and what it does by default.

Trusted:

  • The deploy environment: the machine or Worker, its filesystem / R2 bucket / D1 database, and the process’s environment variables (CRON_SECRET, WEBHOOK_SECRET_*, CMS_TRUSTED_ORIGIN, AI and email keys). Anyone who can read them is an operator.
  • Admin users. An admin can invite users, assign roles, change content, upload files, and configure anything reachable from the admin UI. Editors are trusted to write content but not to change users or roles.
  • Project code: cms.config.ts, collections, adapters, custom fields, and the public site templates. The CMS does not defend the site against its own configuration.

Not trusted:

  • Public visitors. Everything reachable without a session is an attack surface: the public site, /uploads/*, /api/cms/img/*, the login / setup / invite / reset / form-submit endpoints, and the machine endpoints (cron, webhooks).
  • Uploaded files. The bytes of an upload are hostile regardless of who uploaded them. The stored name, extension, and served Content-Type come from the verified type, never from the client.
  • Webhook payloads. A valid HMAC proves the sender holds the shared secret, not that the payload is well-formed; handlers must validate it.
  • Content. Rich text, links, and block data are authored data that reaches every visitor’s browser. Renderers escape text and reject javascript: / unknown URL schemes; project-side renderers that use set:html or dangerouslySetInnerHTML are on their own.
  • Request metadata a proxy could rewrite: Host, X-Forwarded-*, Origin, Referer.

Out of scope: denial of service by resource exhaustion on the public image resizer (put a CDN in front; every rendition URL is cacheable and immutable), and anything that requires an already-compromised deploy environment.

  • Set CMS_TRUSTED_ORIGIN to the site’s public origin. It pins the origin used for the CSRF check and for the links placed in reset and invite emails. Without it those come from the request Host, which the Node adapter does not validate unless Astro’s security.allowedDomains is set.
  • On Node behind a reverse proxy, configure Astro’s security.allowedDomains so clientAddress comes from X-Forwarded-For; otherwise every visitor shares the proxy’s IP for rate limiting.
  • Set CRON_SECRET and WEBHOOK_SECRET_<PROVIDER>; without a secret the cron endpoints answer only in development.
  • Passwords: PBKDF2-SHA256, 16-byte random salt, 32-byte output, 600 000 iterations on Node and 100 000 on Cloudflare Workers (the platform maximum). The iteration count is stored per hash and capped at 1 000 000 on verification. Minimum length 8, maximum 4096. Comparison is constant-time.
  • Session tokens: 32-character nanoid, stored as SHA-256(token). Absolute lifetime 30 days, no sliding renewal. Cookie cms_session: HttpOnly; SameSite=Strict; Path=/, Secure when NODE_ENV=production. Logout deletes the row; a completed password reset deletes every session of that user. Changing a password through the users collection does not revoke other sessions.
  • Password reset: 40-character nanoid, hashed at rest, valid for 1 hour, consumed atomically so it can be used once. The forgot-password response is identical whether or not the email exists.
  • Invites: 32-character nanoid, hashed at rest, valid for 7 days, single-use. Only admins can create them. The raw token is shown once to the inviting admin (for setups without email) and appears in that redirect URL.
  • First-run setup creates the first admin with a conditional insert and is closed once any admin exists.
  • kide-editor=1 is a non-HttpOnly hint cookie that only tells the public-site script whether to ask /api/cms/edit-bar; it carries no authority.
  • Login does the same PBKDF2 work for unknown emails as for wrong passwords.
  • Every non-GET/HEAD/OPTIONS request under /admin and /api/cms must prove same-origin (Origin, or Referer when Origin is absent, must equal the trusted origin; Sec-Fetch-Site: cross-site is rejected). Exempt: cron, webhooks, form submit; each has its own check.
  • Admin responses carry X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, Referrer-Policy: strict-origin-when-cross-origin; auth pages and endpoints add Cache-Control: no-store.
  • Rate limits are stored in the database (cms_rate_limits, atomic upsert; identical on SQLite and D1) and keyed by clientAddress and, where relevant, the target email. Login: 5 failures per 15 minutes per IP and per email, fail-closed. Forgot-password: 5 per 15 minutes per IP and per email, fail-open. Reset, invite accept, setup: 10 per 15 minutes per IP. Form submit: 10 per 10 minutes per IP.
  • Upload requires a session. Allowed types default to JPEG, PNG, GIF, WebP, AVIF, PDF, MP4, WebM; SVG is not in the default list. For the default types and SVG, the file’s header must match its declared type (magic bytes); types added through allowedTypes without a known signature are not byte-checked. Default limit 50 MB per file, 20 files per request, enforced on the request stream.
  • Stored as /uploads/<nanoid>.<ext> where the extension comes from the verified type. A custom-allowed type may only keep its own extension when it is not an active-content extension (html, svg, js, xml, …).
  • The public file routes only serve paths that match /uploads/<segments> with no empty or dot segments, so an object store never returns other keys. The Node adapter additionally resolves under the uploads directory and rejects traversal.
  • /uploads/* responses carry nosniff; SVG (when a project opts in) additionally gets Content-Security-Policy: sandbox. /api/cms/img/* serves untransformed non-raster files as attachments.
  • Dedupe (dedupe: true) is by SHA-256 of the bytes and only used by importers.
  • /api/cms/img/*: public. Resize parameters are snapped to a fixed width list and clamped; format restricted to webp/avif/jpeg/png. Reads uploads only.
  • /api/cms/preview/render: session required, same-origin only, no-store. Renders the caller’s own unsaved content.
  • /api/cms/edit-bar: session required, read access to the referenced collection required, no-store.
  • /api/cms/cron/tasks and /api/cms/cron/publish: Authorization: Bearer $CRON_SECRET, constant-time compare. With no secret set they respond only when NODE_ENV=development.
  • /api/cms/webhooks/[provider]: HMAC-SHA256 hex signature in x-webhook-signature against WEBHOOK_SECRET_<PROVIDER>, verified with crypto.subtle.verify; body capped at 1 MB; payload is only enqueued, never executed.
  • /api/cms/forms/submit/[slug]: public by design. Body 100 KB, 100 fields, 10 000 characters per value, honeypot, per-IP limit; redirect targets are path-relative or pass safeUrl.
  • ?preview on public pages: session required (stripped otherwise), Astro cache disabled, and the response carries Cache-Control: no-store.
  • Rich text and content are stored as JSON, never HTML. Renderers escape text and attributes; href and image src are accepted only for http:, https:, mailto:, tel:, and relative URLs. External links get rel="noopener noreferrer".
  • Admin UI code does not put non-static strings into innerHTML; server-rendered HTML in the preview client comes from the render endpoint above.