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.
Threat model
Section titled “Threat model”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-Typecome 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 useset:htmlordangerouslySetInnerHTMLare 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.
Production settings
Section titled “Production settings”- Set
CMS_TRUSTED_ORIGINto 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 requestHost, which the Node adapter does not validate unless Astro’ssecurity.allowedDomainsis set. - On Node behind a reverse proxy, configure Astro’s
security.allowedDomainssoclientAddresscomes fromX-Forwarded-For; otherwise every visitor shares the proxy’s IP for rate limiting. - Set
CRON_SECRETandWEBHOOK_SECRET_<PROVIDER>; without a secret the cron endpoints answer only in development.
Defaults
Section titled “Defaults”Authentication and sessions
Section titled “Authentication and sessions”- 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 asSHA-256(token). Absolute lifetime 30 days, no sliding renewal. Cookiecms_session:HttpOnly; SameSite=Strict; Path=/,SecurewhenNODE_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=1is a non-HttpOnlyhint 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.
Request checks
Section titled “Request checks”- Every non-
GET/HEAD/OPTIONSrequest under/adminand/api/cmsmust prove same-origin (Origin, orRefererwhenOriginis absent, must equal the trusted origin;Sec-Fetch-Site: cross-siteis 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 addCache-Control: no-store. - Rate limits are stored in the database (
cms_rate_limits, atomic upsert; identical on SQLite and D1) and keyed byclientAddressand, 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.
Uploads and files
Section titled “Uploads and files”- 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
allowedTypeswithout 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 carrynosniff; SVG (when a project opts in) additionally getsContent-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.
Public endpoints
Section titled “Public endpoints”/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/tasksand/api/cms/cron/publish:Authorization: Bearer $CRON_SECRET, constant-time compare. With no secret set they respond only whenNODE_ENV=development./api/cms/webhooks/[provider]: HMAC-SHA256 hex signature inx-webhook-signatureagainstWEBHOOK_SECRET_<PROVIDER>, verified withcrypto.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 passsafeUrl.?previewon public pages: session required (stripped otherwise), Astro cache disabled, and the response carriesCache-Control: no-store.
Rendering
Section titled “Rendering”- Rich text and content are stored as JSON, never HTML. Renderers escape text and attributes;
hrefand imagesrcare accepted only forhttp:,https:,mailto:,tel:, and relative URLs. External links getrel="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.