Site trust¶
Cumments' write API is public and site_id is a self-declared path
parameter. Site authentication binds a site_id to a real website and
enforces that binding on the write path (POST, PUT, PATCH, DELETE), so one
site cannot submit comments as another. It is not the comment author's
identity layer: every visitor still needs the PoW challenge and an Ed25519
signature for authorship, and every write request still carries an
Idempotency-Key.
Reading is always public. Comment lists (QUERY), SSE streams and the
challenge endpoint are served with Access-Control-Allow-Origin: *, because
comments are public data.
Why authentication is needed¶
Without a binding, any client could pick any site_id and post comments to
it. The practical attackers are:
- Other websites' pages that embed a copied widget and post from their own origin;
- Direct API callers (scripts, bots,
curl) that skip the browser entirely; - Site-id squatters who claim an id before its legitimate owner.
Two independent mechanisms address these threats:
| Mechanism | Protects against | Does not protect against |
|---|---|---|
| Origin mode | Other websites' pages posting as your site — browsers cannot forge the Origin header |
Scripts/curl forging Origin; XSS on the site; DNS/TLS compromise |
| Secret mode | Any client without the site secret, including scripts and curl |
Site-secret leakage; compromise of the edge function; DNS/TLS compromise of the API link |
Origin mode is a browser-context binding, not cryptography: it proves which website the browser is on. Secret mode is cryptographic: it proves possession of the site's HMAC key.
Two trust axes¶
Write enforcement is the product of an instance-wide policy and a per-site auth mode.
Instance-wide policy: security.site_verification¶
All three values share one prerequisite: the site_id must exist in the
registry (registered through the API/CLI or declared in [sites]). An
unknown id is rejected with 404 code=site-not-registered before any trust
decision, so an unregistered site can never provision a Matrix Space.
| Value | Unverified sites | Verified or operator-configured sites |
|---|---|---|
disabled |
Writes allowed, no trust checks (local development) | No checks either |
optional |
API-registered: allowed with a WARN log; rows without ownership proof are rejected | Enforced |
required |
Writes rejected with verification guidance | Enforced |
disabled is the only mode that accepts the opaque Origin: null used by
file:// demo pages. optional and required reject it, together with
missing or multiple Origin headers (see Origin mode).
The optional relaxation only covers API-registered sites that simply
have not verified yet (they still hold a claim token). Two cases are rejected
instead, with 403 code=site-verification-required:
- a caller-chosen
site_id— a readable alias has to be backed by a real domain; - a site row with no ownership proof at all — a removed
[sites]entry, a legacy auto-created Space, or a row rebuilt bybackfillafter a database reset.
This keeps the migration escape hatch narrow and makes configuration removal
an immediate tightening: deleting a [sites] entry removes the only trust
anchor, so writes stop rather than falling back to "any origin accepted".
Per-site auth mode: sites.<id>.auth_mode¶
| Value | Trust anchor | Requires |
|---|---|---|
origin (default) |
Browser Origin matched against verified/configured origins |
A verified domain or an operator allowlist |
secret |
HMAC-SHA256 key held by the site backend | A site secret and a backend/edge function |
The site registry¶
The effective trust rule set for a site is the union of two sources:
[sites."<site_id>"]in the TOML configuration: operator-declared trust. This is declarative, version-controlled and human-reviewed, and the operator can skip the self-service verification flow entirely.- Database rows: runtime state created by self-service registration and
verification. These carry provenance (
configorverified) so the Operator API can show where every allowed origin came from.
Configuration is never written back to by the API or the operator tooling.
The public registration endpoint accepts an optional, first-come
site_id; without one the server generates an unguessable random id. Every
registration returns a one-time claim token, and only its holder may
start verification for that site — which prevents a squatter from claiming a
victim's id and verifying it with a domain they control. Operator-configured
sites skip the claim step. Registration is mandatory in every policy: a site
must be operator-configured or registered through the API/CLI before it can
be written to. Chosen ids carry a further obligation: they only become writable after an
origin is verified (see above), so a "pretty name" must be backed by a real
domain.
See Site verification and strict mode for the full registration, verification and key-issuance walkthrough.
Origin mode¶
In origin mode, every write request must carry exactly one Origin header.
The middleware:
- rejects missing, multiple, invalid or
nullorigins withsite-origin-denied; - normalizes the value (scheme + host, default port stripped, punycode, lowercase, no path);
- matches it against the effective allowlist (operator config ∪ verified origins);
- echoes the accepted origin in
Access-Control-Allow-Originand answers preflights for allowed origins.
The only supported wildcard is a subdomain form: https://*.example.com
matches any subdomain of example.com but not the apex. No implicit
localhost or private-network exceptions exist; local development uses
site_verification = "disabled".
Origin-mode CORS is derived from the registry instead of being a global
config value. Preflights allow the write methods, and the allowed headers are
content-type and idempotency-key.
Secret mode¶
Secret mode replaces the origin check with an HMAC signature, so even a non-browser client cannot impersonate the site without the key. It is intended for deployments that proxy comment submissions through their own backend or edge function.
Request signing¶
Every forwarded write request must carry:
X-Cumments-Timestamp: Unix seconds;X-Cumments-Signature: hex HMAC-SHA256 overtimestamp\nMETHOD\npath\nsha256_hex(body),
with the site secret as the key. The timestamp must be within ±5 minutes of
the server clock. The secret is bound to the site_id in the request path;
an Origin header is not required.
The signature does not cover the Host header, so a secret must never
be shared between Cumments instances.
Key lifecycle¶
- The key is generated and returned exactly once at issuance; it is never logged and never exposed through the API again.
- It is stored in the local database in plain form, because verifying an HMAC requires the key itself (unlike claim and verification tokens, which are stored only as SHA-256 hashes). Protect the database file like any other credential store.
- Keys can be rotated or revoked through the Operator API, and can also be
declared directly in
[sites."<id>"]with environment-variable injection.
Secret-mode sites do not accept browser preflights: a browser cannot call the Cumments API directly with an HMAC. The site's own backend performs the signing and forwards the request, so the frontend talks to its own origin.
Effective behavior matrix¶
| Policy | origin mode |
secret mode |
|---|---|---|
disabled |
Any registered site: origin accepted, Access-Control-Allow-Origin: * |
Any registered site accepted (no HMAC check) |
optional |
API-registered unverified sites: accepted with WARN; rows without ownership proof are rejected; verified/config sites: origin must match | HMAC required whenever the site has a secret; sites without one follow the origin/unverified fallback |
required |
Origin must match a verified/config origin | HMAC required for any site configured with a secret; sites without a secret are rejected |
Read methods (GET, QUERY, SSE) are unaffected by this matrix and remain
public.
Configuration¶
[security]
site_verification = "optional" # disabled | optional | required
# Operator token for the Operator API (at least 32 chars); unset disables it.
# operator_token = "<random>"
[sites."test-blog"]
auth_mode = "origin" # origin (default) | secret
allowed_origins = [
"https://blog.example.com",
"https://*.blog.example.com",
]
# auth_mode = "secret" requires a secret; prefer environment injection:
# CUMMENTS__SITES__test-blog__SECRET=...
Validation is fail-fast at startup:
- unknown keys (including a legacy
cors_origins) are rejected instead of being silently accepted; - origins are parsed and normalized; only
http(s)schemes are allowed, without path/query/fragment, and the only wildcard ishttps://*.example.com; auth_mode = "secret"requires a non-empty, non-placeholder secret of at least 32 characters;allowed_originsis ignored (with a warning) whenauth_mode = "secret";security.operator_token, when set, must be at least 32 characters and not a known placeholder.
See configuration.md for the full reference.
Operator operations¶
Database-tracked sites can be managed through the Operator API, authenticated
with security.operator_token sent as Authorization: Bearer <token>:
- list sites with origin provenance;
- revoke a verified origin;
- rotate or revoke an HMAC secret;
- export a TOML snippet to adopt a database-tracked site into
[sites].
Operator-configured origins and secrets cannot be changed through the API — edit the configuration file instead. Operator routes are rate limited to 60 requests per minute per client.
Security notes¶
- Origin mode is a browser-context control: a script or
curlclient can forgeOrigin, so it is only appropriate for pure frontend deployments. - Secret mode moves the trust boundary to the key holder. Keep the key out of the site bundle and rotate it when an edge function or CI environment may have leaked.
- The HMAC secret is stored plaintext in the database by design; the database must be treated as a credential store.
Origin: nullis rejected outsidedisabledmode (a hardening class of CVE-2026-27978);disabledallows it only as the development exception.- Write-path enforcement validates the
site_idformat before any database lookup, so malformed ids fail fast.
Related documentation¶
- Site verification and strict mode — walkthroughs for SSG sites, DNS/well-known proofs and edge-function examples.
- Configuration — the full configuration reference.
- API — endpoint reference, including registration, verification and secret issuance.
- Problem types — the RFC 9457 error registry used by the write path.