embed-cms holds content, user accounts and files, and often sits on the public internet, so its protections matter. The short version:
- Every protection is on by default, in development and in production: CSRF checks, secure cookies, security headers, scrypt password hashes, upload limits, safe attachment downloads, authenticated replication and more. There is no weaker profile to fall back to; the
security.profilesetting of earlier releases is refused at boot. - Run production with
NODE_ENV=production. Two things follow it: strong secrets are required (boot is refused with a missing, short or publishedauth.secretorsession.secret) and the built-inlocalAdminaccount is not created. Development keeps both, so a fresh checkout can log in withlocalAdmin/localAdmin. - Any single protection can be set on its own in the
securityblock ofcms.json.
This page lists every setting with its default, gives the recommended production configuration and a hardening checklist, and says how to report a vulnerability. The other options of cms.json are in docs/reference/CONFIG.md.
Please do not open a public issue for a security problem.
- Send the details privately to the maintainers: open a private security advisory on the GitHub repository (Security tab, "Report a vulnerability").
- Say which version or commit you tested, what you did, what you saw, and what you expected. A failing request or a short script is the best report.
- You will get an answer within a few working days. Fixes are released with an entry in the changelog below, and the reporter is credited if they wish.
Supported versions: the latest release and the branch it was made from. Fixes are not backported.
Each protection is one setting in the security block of cms.json. All of them are on by default; the table shows the default and what turning one off means. Only strongSecrets and localAdmin depend on NODE_ENV (see above).
{
"security": {
"csrf": "origin",
"allowedOrigins": ["https://admin.example.com"],
"localAdmin": false
},
"trustProxy": 1
}| Setting | Default | Effect |
|---|---|---|
strongSecrets |
true in production, false otherwise |
Boot is refused when auth.secret or session.secret is missing, shorter than 16 characters or one of the values published in the source |
generateSecrets |
false |
With strongSecrets: generate the missing secrets once and keep them in <data>/.secrets.json (mode 0600) instead of refusing to boot |
localAdmin |
false in production, true otherwise |
Create the built-in localAdmin account. When off, an existing localAdmin record cannot log in with the password localAdmin. Whatever the setting, a boot log line reports an existing localAdmin record: an error when it still has the default password, a warning otherwise |
passwordHash |
scrypt |
Scheme for new and changed passwords: legacy (PBKDF2-SHA1, 100 rounds) or scrypt (N=2^15, r=8, p=1, versioned). Both formats always verify; a legacy hash is rewritten as scrypt at the next successful login when scrypt is selected |
hideCredentials |
true |
No password hash or salt in the JWT, the login response or find() results; tokens carry a fingerprint that notices a password change |
genericLockout |
true |
A locked account answers 429 with Retry-After and one message for every account |
blockRetry (top level) |
{ "retry": 10, "duration": 5 } |
Failed logins tolerated per account and address, and the lock duration in minutes. false turns it off |
csrf |
origin |
Cookie authenticated writes, and the routes that change state on GET, are refused when Origin, Referer or Fetch Metadata says they come from another site |
allowedOrigins |
[] |
Origins (https://host) that count as the same site for csrf and for the update websocket |
cookies |
httpOnly: true, sameSite: "lax", secure: "auto" |
Attributes of the JWT and session cookies. secure: "auto" needs trustProxy behind a TLS proxy |
strictSessions |
true |
No session is created for anonymous and Basic authenticated requests (resave and saveUninitialized are forced to false) |
redactConfig |
true |
/admin/config doesn't carry the credentials of import, importFromRemote, sync, syslog |
strictAdmin |
true |
/admin/_groups needs a login, theme names are validated |
headers |
true |
Response headers from lib/util/securityHeaders.js (CSP, nosniff, frame options, Referrer-Policy, Permissions-Policy, COOP, HSTS over https, no X-Powered-By) instead of the historic helmet set |
contentSecurityPolicy |
(default policy) | A policy string, or false for none |
sseCors |
[] |
Origins that may read /api/system and /api/_syslog; [] means the same origin only |
safeRegex |
true |
Queries with a regular expression that can backtrack catastrophically (nested repetitions, repeated alternatives, back references, more than 3 unbounded repetitions) are refused with 400 |
uniformErrors |
true |
Every error that no plugin answered ends as a json body without stack trace or path |
safeAttachments |
true |
HTML, SVG, XML, script and unknown attachments are sent as downloads with a sandbox policy; PUT can only change _name, cropOptions, order, _payload, _fields, _filename |
inlineTypes |
[] |
Extra content types that may be shown inline with safeAttachments |
strictUploads |
true |
File names are sanitised, the type of an upload is taken from its content, default upload limits apply |
limits.json |
"100kb" |
Size of a JSON request body, on every route (REST, sync, import, the admin): it is the only JSON limit. A larger body answers 413 with a JSON message naming this setting. 100kb is what every route accepted before the option existed |
limits.upload |
{ fileSize: "256mb", files: 20, fields: 200, fieldSize: "1mb", parts: 260 } |
Limits of multipart uploads; a value given here replaces the matching default |
restrictRemoteUrls |
true |
importFromRemote only fetches attachments from the remote it imports from (and allowedHosts of its remote block) |
strictReplication |
true |
Peers may only ask for resources this node has, and each record a node pulls from a peer is validated before it is stored. The replication websocket always needs replication.secret |
wsAuth |
true |
The record update websocket needs a login and a same-origin (or listed) page |
wsMaxPayload |
16384 |
Largest message a websocket client may send |
authCacheTtl |
60000 |
Milliseconds a verified password stays verified in memory (Basic authentication runs the hash once per minute, not per request); 0 turns it off |
| Option | Default | Effect |
|---|---|---|
trustProxy |
not set | Passed to Express trust proxy: req.ip, req.secure and the login lockout follow the proxy chain. Set it to the number of proxies in front of the CMS; without it the address of the TCP peer is used |
imageConcurrency |
number of cores | Image operations (resize, crop, smart crop) that run at once; the others wait. 0 means no limit. At most 100 may wait, the next request gets 503 |
attachmentCleanupGrace |
300000 |
Milliseconds a file must have been idle before cleanAttachment may remove it as an orphan |
replication.secret |
not set | Shared secret of the replication websocket (/_replicator on the HTTP port). Both sides prove they know it with an HMAC-SHA256 challenge before any resource is named; without it the node doesn't listen for peers |
replication.strictTypes |
false |
Read the type (normal, upstream, downstream) of a resource from its options, so the direction of each peer is checked against it. Off, every peer is used for every resource |
replication.pollInterval |
30000 |
Milliseconds between two pulls from a peer whose store can't keep a link open (MongoDB, PostgreSQL) |
replication.maxRecordBytes |
4194304 |
Largest record accepted from a peer with strictReplication |
importFromRemote.remote.restrictUrls / .allowedHosts |
false / [] |
Per remote, in addition to restrictRemoteUrls |
replication.strictTypes is worth a word. A node pulls each resource from the peers it lists, except a resource of type upstream, which it only sends. Off, a node opens a link to every peer it lists. With the option on, it opens links only to peers whose direction is upstream or normal, the peers it takes from; a peer without direction then gets no link. Give every peer a direction before turning it on. A peer that connects to a node can pull from it but never writes to it, with or without the option. REPLICATION.md explains the types and the directions.
These behaviours are not policy. They hold whatever the settings:
pagewithout aqueryreturns that page, and the RESTnumRecordsheader is computed without reading every record twice;- filtered reads of
_usersnever returnpasswordorsalt; GET /admin/changeTheme/...stores the theme and leaves the password hash alone;- a user name that is not a string can't be used as a query to log in, and the login lockout counts it;
- login for an unknown account takes as long as for a known one (timing);
- the session id is replaced at login, logout destroys the session and revokes the token (the list of revoked tokens survives a restart,
<data>/.revoked-tokens.json), a changed password ends the sessions and tokens of that user; - in production the session files (
<data>/.sessions.json) are kept in a folder that only the account of the server can enter. A session holds the login token of a person in clear text, so a file that another account of the machine could read would be a login that can be taken. The folder is set to mode 0700 at every start, also when an earlier version made it with the permissions of the umask; - uploaded temporary files are removed after the request and kept in a private folder; xlsx exports use a private file that is deleted after sending;
- a replication peer can't make the node create folders outside its data directory, an error event on a socket or a websocket doesn't end the process, a malformed frame doesn't crash the sync;
- the json file store keeps a file it cannot parse aside (
db.json.corrupt-<time>) instead of overwriting it, and writes through a temporary file that is renamed; - outbound replication and the sync of the json store run with the current store library;
cleanAttachmentanswers, and removes only orphans older thanattachmentCleanupGrace;- record ids of one process sort in the order they were made, and use a cryptographic random source;
importFromRemotedownloads a file once, doesn't log the password, and sends the credentials only to the remote;- an error or a rejected promise inside a driver method (a query with
$type, for instance) ends the call with that error, and$typein a query answers400because a JSON query can't express it; exists(id)answersfalsefor a record that is not there;- the query of
getImportMap(used by the import) understands$in,$gtand the other operators, and is validated like any other client query; - the PostgreSQL and MongoDB stores release their connections when the CMS closes, and a dropped idle PostgreSQL connection doesn't end the process;
- a write to a resource with no replication peer doesn't log an error with a stack trace.
This is the cms.json for a production deployment, with every choice written out instead of relying on the defaults (a setting only has to be written when it differs from them). test/unit/securityConfig.test.js reads the block below, so option names and values in it are checked against the code. Replace the three REPLACE_WITH_* values with random strings of at least 32 characters (they belong in a secret store, never in version control), and put your own peers in replication.
{
"auth": { "secret": "REPLACE_WITH_RANDOM_AUTH_SECRET" },
"session": { "secret": "REPLACE_WITH_RANDOM_SESSION_SECRET" },
"trustProxy": 1,
"blockRetry": { "retry": 10, "duration": 5 },
"attachmentCleanupGrace": 300000,
"replication": {
"secret": "REPLACE_WITH_RANDOM_REPLICATION_SECRET",
"strictTypes": true
},
"security": {
"strongSecrets": true,
"localAdmin": false,
"passwordHash": "scrypt",
"hideCredentials": true,
"genericLockout": true,
"csrf": "origin",
"allowedOrigins": [],
"cookies": { "httpOnly": true, "sameSite": "lax", "secure": "auto" },
"strictSessions": true,
"redactConfig": true,
"strictAdmin": true,
"headers": true,
"sseCors": [],
"safeRegex": true,
"uniformErrors": true,
"safeAttachments": true,
"strictUploads": true,
"restrictRemoteUrls": true,
"strictReplication": true,
"wsAuth": true,
"wsMaxPayload": 16384,
"authCacheTtl": 60000,
"limits": { "json": "100kb" }
}
}The values that depend on your setup:
trustProxyis the number of reverse proxies in front of the CMS; change1to the real number.allowedOriginstakes the origin of the admin app when it is served from another host than the API.limits.jsonis"100kb", the size of the largest JSON body (a record, an import); raise it when a record is bigger.replication.strictTypesis on, so every peer needs adirection, or it syncsnormalresources only.localAdminis off. AlocalAdminrecord that already exists stays, and every boot logs an error while it has its default password: change that password or delete the account.safeAttachmentsandstrictUploadsare on. TurningsafeAttachmentsoff displays HTML and SVG attachments inline, with the privileges of the site for whoever opens them. TurningstrictUploadsoff removes the file name sanitising, the content-based type and the upload limits.
The block leaves out what depends on your environment: imageConcurrency (one per core by default), the importFromRemote remotes (restrictUrls, allowedHosts per remote) and the peers list.
In development, run without NODE_ENV=production and without this block. The defaults then apply, with localAdmin and the default secrets allowed.
The default Content-Security-Policy is written for the built admin app. It lets through the tile server and the search of the map of the geopoint field: OpenStreetMap unless the maps option says otherwise, and nothing with maps: false (see CONFIG.md). A plugin page that loads from another origin needs its own contentSecurityPolicy.
Run with NODE_ENV=production, then check:
- Secrets. Set
auth.secretandsession.secretto random values of at least 32 characters incms.json(or setsecurity.generateSecretsand back up<data>/.secrets.json). Keep the file out of version control. - Accounts. Log in once with your own administrator and delete
localAdminif it exists. Do not setsecurity.localAdmin. - Password hashes. Leave
passwordHashonscrypt. If nodes replicate_users, upgrade every node first: a node that runs an earlier release cannot verify an scrypt hash. - Proxy. Behind a reverse proxy, set
trustProxyto the number of proxies, forwardX-Forwarded-Proto, and serve the admin app over https (cookies getSecure, HSTS is sent). - CSRF and origins. If the admin app is served from another origin than the API, list it in
allowedOrigins. - Replication. Set the same
replication.secreton every node, serve the CMS over HTTPS so peers replicate overwss://, and set adirectionon every peer before turning onreplication.strictTypes. - Uploads. Tune
limits.uploadto what your editors need. Do not addtext/htmlorimage/svg+xmltoinlineTypesunless you trust everyone who can upload. - Configuration.
cms.jsonis not editable from the admin: nothing on the web can read or rewrite the secrets. - Logs.
/api/_syslogshows the log to every user who can log in: keep secrets out of log lines (the backend does not write passwords or tokens) and give thepluginsright of the group only to administrators. The log page and the log file are bounded bysyslog.maxLineLength,syslog.maxFileSizeandsyslog.maxClientBuffer. - Dependencies. Run
npm audit --omit=devin CI (the workflow does).
- The frontend (
src/) was not changed by this work. Its own protections (DOMPurify) are separate. - Transport encryption of the replication: the shared secret authenticates the peers, it does not encrypt what they exchange. Serve the CMS over HTTPS (peers then use
wss://), or keep the nodes on a private network or a tunnel. - MongoDB and PostgreSQL servers are configured by their operators; the CMS only guarantees the queries it sends.
- Regular expressions are checked, not sandboxed: a pattern that passes the check can still be slow on a very long text field. If that matters to you, keep
limits.jsonsmall and consider a regular expression engine with a linear time guarantee in front of the query filter.
- The configuration editor is removed.
/admin/cms-config(a page that rewrotecms.jsonfrom the browser and restarted the process) no longer exists: nothing on the web can read or change the secrets, the ports or the plugin settings. Editcms.jsonon the server. Hardening item 8 and theredactConfigsetting follow from it. - The mode without any authentication works. With
disableAuthenticationanddisableJwtLoginboth on, the admin used to reload for ever (the schemas of the paragraphs answered 401), showed nothing (theanonymousgroup had no right) and signed writes "anonymous~false". Theanonymousgroup now has every right in that mode, and an error line in the log says so at every start. - The log cannot exhaust the disk or the memory. A line over
syslog.maxLineLengthis cut on the log page, a log file oversyslog.maxFileSizeis rotated to<path>.1, and a page that stops reading is dropped aftersyslog.maxClientBufferbytes (see CONFIG.md). - The cookies are renamed with the product and named after the server. The login token is
embedCmsJwt-<mid>, the session isembedCmsSid-<mid>, and the session key isembedCmsUser. Everyone is logged out once at the upgrade.- Browsers share the cookies of a host between its ports. Two CMS on
localhostused to overwrite and clear each other's login. Now each keeps its own. - A client that sends the login token as a cookie must use the name of the server. The
x-access-tokenheader needs no name, andimportFromRemotenow sends the token in that header.
- Browsers share the cookies of a host between its ports. Two CMS on
Options added, with defaults (see the tables above):
strongSecrets,generateSecrets,localAdmin,passwordHash,hideCredentials,genericLockout,csrf,allowedOrigins,cookies,strictSessions,redactConfig,strictAdmin,headers,contentSecurityPolicy,sseCors,safeRegex,uniformErrors,safeAttachments,inlineTypes,strictUploads,restrictRemoteUrls,strictReplication,wsAuth,wsMaxPayload,authCacheTtl,limits.json,limits.upload- top level:
trustProxy,imageConcurrency,attachmentCleanupGrace;blockRetry: falseturns the lockout off replication.secret,replication.strictTypes,replication.pollInterval,replication.maxRecordBytesimportFromRemote.remote.restrictUrls,importFromRemote.remote.allowedHosts
Files written by the backend: <data>/.secrets.json (only with generateSecrets), <data>/.revoked-tokens.json, <data>/<resource>/json/db.json.corrupt-<time> (only when a store file could not be read).
Phases, in order (each is one commit on the branch):
- Audit of the backend.
- Authentication and secrets: secret policy,
localAdmin, scrypt hashes, no credentials in tokens, lockout, cookies, sessions, CSRF, configuration editor. - HTTP hardening: headers, event stream CORS, regular expression check, error handler, size limits, uploads, attachment serving,
importFromRemote. - Replication and sync: authenticated handshake, validation of what a peer sends, websocket authentication,
strictTypes, and the repair of the sync of the json store. - Performance: sorted key index and range search in the json store, query prefilter, paging, adaptive and sliced flush, indexed import map, one read per export sheet, verified password cache, image concurrency limit.
- Database drivers: contract suite for the json store, PostgreSQL and MongoDB, and the fixes it found.
- Cleanup and documentation.