Security
A choir's site holds names, email addresses and sometimes donations and orders. This page lists what to do before you go live, what the software does for you, and what it does not do, so you know where you must add protection yourself. It is for whoever runs the server.
Checklist before going live
- Serve the site over HTTPS only, through a reverse proxy or your platform's own HTTPS. See Put it behind HTTPS. In production the login cookie is marked
Secure, so over plainhttpbrowsers will not keep it and nobody can stay logged in. - Use a long admin password (the site warns below 12 characters), or give each admin their own login with
ADMIN_AUTH=table. See Admin sign-in. - Give each provider key the least it needs. The email key should be able to send mail and nothing else (for Amazon SES, only
ses:SendEmail). The storage key should reach only the two buckets. The database user should own only this site's database. - Keep the private bucket private. Never give the private store (member files and rehearsal tracks) a public address. The app checks who is asking before it serves anything from it. On Cloudflare, give a custom domain only to the public bucket.
- Close the app's own port to the world. The server listens on all network interfaces (see below), so let only the reverse proxy reach it, with a firewall.
- Protect the environment file. It holds passwords and keys. Make it readable only by the service user:
chmod 600 choirmaster.env. - Turn on the bot check for the contact form. See Stop spam with a bot check.
- Set
SITE_URL, so emailed links point to your site and not to an address someone else controls. - Set up backups and test a restore: What to back up.
- Add security headers at the proxy (see "What the software does not do").
- Run
npm run config:checkand fix everything it lists. See Check your configuration.
What the software does
Logins and sessions
- Session ids are 32 random bytes. Nothing in them is guessable and nothing is signed: the id is only a key to a session held on the server (in the database, Redis, KV or memory, as
SESSION_STOREsays). - Cookies are
HttpOnly(scripts on the page cannot read them),SameSite=Lax, andSecurein production. They have noDomain, so they belong to your site's exact host name. Admins and members have separate cookies (cm_admin_sessionandcm_member_session), so one browser can be logged in as both and each logout ends only its own. The cookie lifetime and the stored session lifetime are always set to the same value (24 hours for admins,ADMIN_SESSION_HOURS; 30 days for members,MEMBER_SESSION_DAYS). - Disabled and deleted accounts are logged out at once. With
ADMIN_AUTH=tablethe admin's account is checked on every request, and members are re-read on every request. - The admin password is compared without leaking timing: both sides are hashed first and the hashes are compared in a way that takes the same time wherever they differ.
Login links
Members (and admins with ADMIN_AUTH=table) log in by an emailed link. The link:
- is a random 32-byte token, and only its SHA-256 hash is stored, so reading the database never gives a usable link;
- expires after 15 minutes and works once, even if two requests race with it;
- is limited to one email a minute and five an hour for each account, which stops the form being used to flood someone's inbox;
- gets the same answer for every address, whether or not the address belongs to an account, so the form cannot be used to find out who is a member;
- is built only from
SITE_URLin production, never from the request, so nobody can make the site send a link that leads somewhere else; - opens a page that needs a click, because mail scanners follow links in messages and would otherwise spend them.
Other protections
- Cross-origin requests (CORS) are refused unless the origin is listed in
ALLOWED_ORIGINS, and then it is matched exactly, never by part of the text. The site and its API normally share one origin, so you do not set this. - Private files (member files, rehearsal tracks) are only ever sent by the app after it has checked who is asking. A member can fetch a track only if its song is in a published lineup. Responses never contain storage keys. Private files are served with
Content-Security-Policy: default-src 'none'; sandboxandX-Content-Type-Options: nosniff, and public files withnosniff. - Formatted text written by admins (announcements, pages, rich-text settings) is cleaned on the server every time it is saved, whatever sent it. Only these tags are kept:
p,br,strong,em,u,s,h2,h3,ul,ol,li,blockquoteanda. Scripts, frames, styles and everything else are removed. A link may go only to a web page or an email address. - Updates are signed. Downloaded code runs only after its SHA-256 and its Ed25519 signature are checked against a key built into the site. The release server cannot push code by itself. See When an update fails.
- Secrets stay out of sight.
npm run config:checkshows a secret as asterisks and the word "(set)", never the value; the Updates page is never given the licence key. - The container runs without privilege. The Docker image switches to the unprivileged
nodeuser before starting. - The custom style sheet is restricted. A Site Admin on a self-hosted site may add CSS under Site Settings > Theme, and only a Site Admin. It can be at most 20,000 characters, and is refused (never quietly repaired) if it contains a backslash,
<,@import,expression(,javascript:,behavior:,-moz-bindingorimage-set(, or anyurl()that is not a path on your own site or adata:image/address. It applies to public pages only, never to the admin panel or the member portal, so it cannot lock you out. See Add a custom style sheet. - A restricted admin role. Managers cannot change Site Settings, Pages, the mail server or updates. See The three kinds of admin.
What the software does not do
These are yours to arrange.
- It sets no security headers on pages. There is no
Strict-Transport-Security,Content-Security-Policy,X-Frame-OptionsorReferrer-Policyon the site's own pages. Add the ones you want in your reverse proxy. If you add aContent-Security-Policy, it must allow whatever your bot check provider needs (Turnstile, hCaptcha or reCAPTCHA load a script and a frame) and Google Fonts (fonts.googleapis.comfor styles andfonts.gstatic.comfor fonts), and your own site for everything else. Try it in report-only mode first. - It has no setting for the address it listens on. The server listens on all network interfaces on
PORT. Use a firewall or, in Docker, publish the port on the loopback address only if the proxy is on the same machine: change"${HTTP_PORT:-8080}:3001"to"127.0.0.1:${HTTP_PORT:-8080}:3001"in the compose file. DATABASE_SSL=truedoes not verify the database server's certificate. It turns on an encrypted connection to PostgreSQL or MySQL but accepts any certificate, so it protects against eavesdropping, not against someone impersonating the database. For an untrusted network use a private network, a tunnel or a VPN between the site and the database.- There is no general rate limit. Only emailed login links are limited (above). The admin password form does not limit failed attempts, and the contact form is limited only by the field sizes and the bot check. Use a long admin password,
ADMIN_AUTH=tableif you can, and rate limiting at your proxy or firewall for/api/auth/login(for example with Caddy, nginx or Cloudflare rules), and the bot check on the contact form. - It does not encrypt files at rest or the database file. Protect the disk and your backups.
- It does not back anything up. See What to back up.
What leaves the server
| What | Where it goes | When |
|---|---|---|
| Update check: licence key, version, platform, host name, channel | The release server (UPDATE_URL) | When an admin opens the dashboard or Updates page, at most every twelve hours. Never without a licence key. Details. |
| Email: login links, announcements, contact messages | Your email provider | When they are sent. |
| Bot check: the visitor's response token and IP address | Your bot check provider (Cloudflare, hCaptcha or Google) | When the contact form is submitted, if a provider is set. Visitors' browsers also load the provider's script. |
| Files | Your S3-compatible storage or Cloudflare R2 | When they are stored or fetched, if you use them. |
| Writing assistant: the facts an admin typed in the guided setup, and a request for a piece of writing | Anthropic's API (or the address in ANTHROPIC_BASE_URL) | Only if ANTHROPIC_API_KEY is set, and only when an admin asks for text. Limited to AI_DAILY_LIMIT (30) pieces a day. |
| Fonts | Google Fonts, from your visitors' browsers, not from the server | Whenever a visitor loads a page whose theme uses a Google font. |
Apart from these, the site's own code makes no outside requests: it has no analytics, no tracking scripts and no calls home besides the update check. Card payments are available on the hosted service only.