This document covers cmem-server's threat model, the controls already implemented, what you must do as an operator, and how to disclose a vulnerability.
- Threat model
- Built-in controls
- Hardening checklist
- Cryptography
- Token lifecycle
- JWT secret rotation
- HTTPS
- IP propagation
- Rate limiting
- Audit logging
- Reporting a vulnerability
cmem-server is a self-hosted, single-tenant or small-team service. The realistic adversaries:
| Adversary | Goal | In scope? |
|---|---|---|
| Network attacker (TLS unconfigured) | sniff JWTs / passwords | yes — must run behind HTTPS |
| Brute-force login | guess weak passwords | yes — argon2id + login throttle |
| Lost / stolen device | use machine token long after | yes — DELETE /api/machines/:id |
| Malicious authenticated user | escalate to admin | yes — is_admin flag, audit log |
| Other authenticated user | read someone else's project | yes — owner / share permission checks |
| Operator (root on the host) | read DB | out of scope (encrypt at rest at OS layer) |
| Supply-chain attack on dependencies | inject backdoor | partial — Cargo.lock checked in, deps reviewed quarterly |
| Side-channel timing on argon2 | extract password material | out of scope (argon2id constant-time) |
| DDoS | crash the service | out of scope (use Cloudflare / fail2ban / proxy rate limit) |
Out-of-scope is not "we don't care" — for those threats, the mitigation lives outside the application boundary. SECURITY.md flags each one explicitly so you know where to look.
- Passwords: argon2id, RFC 9106 defaults (19 MiB / 2 iter / 1 thread). Hashes only — plaintext never written to disk or logs.
- JWT secret: 256-bit random; auto-generated on first start if the config field is empty.
- Tokens stored as SHA-256: refresh tokens and machine tokens are hashed before insert; the database never holds the plaintext.
- Machine token format:
cmt_<32-char nanoid>— collision probability is<10^-30even at billions of machines. - Sqlx compile-time SQL checks: every query is validated against
the schema at
cargo buildtime. No runtime string interpolation into SQL. unwrap()ban outside test code, enforced by code review +cargo clippy --workspace -- -D warnings.- Body size limits: 8 MiB JSON, 32 MiB sync push.
- CORS off by default — same-origin only; admin web is on the same origin as the API.
- Soft delete with global filtering: every observation /
project_share query joins
deleted_at IS NULL/revoked_at IS NULL. - Admin gate:
require_adminmiddleware checksis_admin = 1 AND is_active = 1on every/api/admin/*and protected/admin/*hit. - Audit log: every write goes into
audit_logwith user_id, machine_id (if any), action, and target. Exported as CSV from the admin web.
Tick these before exposing the service to the public:
- Bind to
127.0.0.1:8080, never0.0.0.0directly to the internet. - Reverse-proxy HTTPS termination via Caddy / nginx with Let's Encrypt or a managed cert.
- HSTS enabled at the proxy (
max-age=31536000; includeSubDomains). The Caddy template does this for/admin/*. -
[auth].require_invite = trueif anyone other than you can reach/api/auth/register. - Rotate the bootstrap admin password immediately.
- Deploy
fail2banor equivalent for SSH (cmem-server has built-in login throttle but the OS still needs hardening). - DB file permissions:
0640 cmem:cmem(default with install-server.sh). - Config file permissions:
0640 root:cmem(the JWT secret is in there). - Daily backup to off-host storage (DEPLOYMENT.md#backups).
-
journalctl --rotateconfigured so the audit log doesn't fill/var/log. - Monitor
audit_logforauth.login_failedspikes andadmin.user_create/admin.user_promoteevents.
| Where | Algorithm | Notes |
|---|---|---|
| Password hash | argon2id | RFC 9106 defaults; configurable in [auth] |
| JWT signing | HS256 | 256-bit secret in [auth].jwt_secret |
| Refresh token | random 32-byte → SHA-256 in DB | rotated on every refresh |
| Machine token | cmt_<32 nanoid> → SHA-256 in DB |
TTL configurable |
| Share link token | 32-char nanoid | stored as plaintext (anonymous lookup) |
All randomness comes from OsRng (getrandom syscall on Linux,
SecRandomCopyBytes on macOS).
Asymmetric crypto is intentionally absent: the service is single-server, all traffic is bearer-token over TLS, and the operational complexity of key management isn't justified at this scale.
register / login
|
v
+-----+--------------------------+
| access JWT, TTL 15 min default | --> sent on every authenticated call
+--------------------------------+
| refresh JWT, TTL 30 days | --> only used to mint new access JWTs
+--------------------------------+
|
v
refresh
|
+--> new access + new refresh; old refresh revoked
|
v
logout
|
+--> refresh revoked, access expires naturally
machine register
|
v
machine token cmt_<32> (TTL 180 days default)
|
+--> use for /api/sync/{push,pull} without round-tripping login
+--> revoke via DELETE /api/machines/:id
change-password invalidates all refresh tokens for the user.
logout invalidates only the supplied refresh token. Access tokens
are stateless (HS256-signed); they are only invalidated by waiting out
their TTL or by rotating jwt_secret.
When to rotate:
- on initial install (auto)
- if the host is compromised
- if the secret is committed anywhere by accident
- on a routine schedule (yearly is more than enough at this scale)
How:
sudo sed -i "s/^jwt_secret = .*/jwt_secret = \"$(openssl rand -hex 32)\"/" /etc/cmem-server.toml
sudo systemctl restart cmem-serverEffect: every active session is invalidated. Users must claude-mem sync login again; machines must re-register.
You can introduce a graceful transition by running both old and new secrets briefly through a forked deployment, but the simple "rotate + restart + everyone re-logs-in" path is what the codebase supports today.
cmem-server has no built-in TLS. Every operator must front it with
a TLS-terminating reverse proxy. The included Caddy template
(deploy/caddy/Caddyfile.example) is the lowest-friction option —
Caddy auto-issues, auto-renews, and auto-redirects HTTP → HTTPS.
If you absolutely need TLS in-process (e.g. air-gapped network with no
proxy), patch crates/server/src/server.rs to use axum_server::bind_rustls.
The codebase doesn't include this by default because it adds a heavy
crypto dependency for a feature 99 % of users don't need.
For audit_log to record the real client IP (not the reverse
proxy's), the proxy must add X-Forwarded-For and cmem-server must
trust it. The included Caddy snippet does the first half:
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}cmem-server reads X-Real-IP first, falls back to
X-Forwarded-For (last entry), and finally to ConnectInfo. Only
trust these headers when the request comes from your reverse proxy —
if cmem-server is reachable from the internet directly, anyone can
spoof them. Bind to 127.0.0.1 to make spoofing impossible.
- Login attempts: 5 failures per IP per 15 minutes (in-memory; survives process restart only by re-counting from the audit log on the next failure).
- Other endpoints: not rate-limited at the application layer. Use the reverse proxy:
{
rate_limit {
zone api 10r/s burst=20
}
}For nginx, see limit_req_zone. For Cloudflare, use Rate Limiting
Rules in the dashboard.
Every write hits audit_log. Examples of recorded actions:
auth.register auth.login auth.login_failed
auth.refresh auth.logout auth.change_password
auth.password_reset machine.create machine.revoke
project.create project.update project.delete
project.fork share.create share.update
share.revoke sync.push sync.pull
admin.user_create admin.user_update admin.user_delete
admin.user_promote admin.user_demote admin.password_reset
admin.invite_create admin.invite_revoke admin.export
Inspect from the admin web (Audit tab) or the CLI:
sudo -u cmem /opt/cmem-server/cmem-server -c /etc/cmem-server.toml \
admin audit --user alice --limit 100The table is small (one row per write); no rotation is necessary at
the scale this server is built for. Export to long-term storage
quarterly via admin export audit.csv.
Email security@bjarne.example.com (or open a draft GitHub Security Advisory at https://github.com/bjarne/cmem-server/security/advisories). Please include:
- A reproducer (curl commands, code snippet, or PoC repo)
- Affected versions / commit hash
- Impact assessment
We will:
- Acknowledge within 72 hours.
- Triage within 7 days.
- Push a patched release within 30 days for high severity, 90 days for medium / low.
- Credit you in the release notes (unless you prefer otherwise).
This project does not currently run a paid bug bounty.
This section pins down the actual behavior of the in-process
hardening layer so you don't have to read the source. Configure these
under [security] in server.toml. Defaults are conservative.
[security]
trusted_proxies = ["127.0.0.1/32", "::1/128"]
login_rate_per_minute = 5
api_rate_per_minute = 60
csrf_enabled = trueThe middleware in crates/server/src/middleware/ip.rs resolves a
single canonical "real client IP" per request and injects it into
request extensions as ClientIp. All downstream consumers
(rate-limit key extractor, audit log, users.last_login_ip,
users.registration_ip) use that value.
Algorithm:
- Take the peer address from axum
ConnectInfo. - If the peer is not inside any
trusted_proxiesCIDR, ignoreX-Forwarded-Forentirely and use the peer IP. This is the anti-spoof guard: an attacker on the open internet cannot inject a fakeX-Forwarded-For. - If the peer is trusted, scan
X-Forwarded-Forfrom right to left and pick the first IP that is not intrusted_proxies. That IP is the closest non-proxy hop and therefore the real client. If the entire chain is trusted (internal traffic), fall back to the leftmost entry.
Operator examples for trusted_proxies:
| Topology | Recommended value |
|---|---|
| Caddy/nginx on the same host | ["127.0.0.1/32", "::1/128"] (default) |
| Caddy in a Docker bridge network | ["172.16.0.0/12"] |
| Cloud LB on private network | ["10.0.0.0/8"] |
| Multiple proxies (CDN → LB → app) | union of all hops, e.g. ["10.0.0.0/8", "172.16.0.0/12"] |
If a trusted_proxies entry fails to parse it is logged via
tracing::warn! and dropped; the process never panics on bad config.
Built on tower_governor with a custom key extractor that uses
ClientIp from the request extensions (i.e. limits per real client,
not per reverse-proxy address).
| Endpoint group | Limit |
|---|---|
POST /admin/login /api/auth/login /api/auth/register |
login_rate_per_minute (default 5) |
/api/admin/* (all admin REST API) |
api_rate_per_minute (default 60) |
| Other endpoints | not rate-limited in-process; use reverse proxy |
When the limit is exceeded the response is 429 Too Many Requests. The window is a token bucket (60 000 / N ms refill,
burst = N). Replenishment is in-process memory; restarting the
service resets all buckets.
The login limiter is layered outside CSRF on /admin/login, so a
brute-forcer can't even reach the CSRF check after burning their
quota.
Enabled when csrf_enabled = true (default). Applies to state-
changing methods on /admin/* (POST, PUT, PATCH, DELETE). It does
not touch /api/admin/* because that surface is JSON + bearer JWT
already.
Mechanism: double-submit cookie.
- On any GET under
/admin/*, the middleware ensures acmem_admin_csrfcookie exists. If absent, it generates a fresh 32-byte random hex token, setsSet-Cookie: cmem_admin_csrf=<token>; HttpOnly; Path=/admin; Max-Age=86400; SameSite=Strict, and injectsCsrfTokeninto request extensions so templates can render<input type="hidden" name="_csrf" value="...">. - On a state-changing request, the middleware reads the URL-encoded
form body, extracts the
_csrffield, and compares it byte-for- byte against the cookie value. - Mismatch / missing →
403 Forbiddenwith a tiny HTML "reload-and-retry" body. Atracing::warn!is emitted with method/uri so legitimate clients with stale tabs are visible in logs.
Why this works: a cross-site attacker can't read the cookie value
(SameSite=Strict + HttpOnly), so they cannot forge a valid _csrf
form field, so the comparison always fails.
If you must turn CSRF off (debugging, scripted admin), set
csrf_enabled = false — the middleware then still keeps the cookie
fresh but skips validation.
Quick smoke check after deploy (replace host as needed):
# 6 rapid POST /admin/login from the same IP — sixth must 429.
for i in $(seq 1 6); do
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST -d "username=ghost&password=foo" \
https://your.host/admin/login
doneSpoof check (only meaningful if you exposed cmem-server directly to the internet — don't):
# trusted_proxies = ["127.0.0.1/32"], but request comes from internet
# with a forged header. All six should still hit the same bucket and
# the sixth must 429.
for i in $(seq 1 6); do
curl -s -o /dev/null -w "%{http_code}\n" \
-H "X-Forwarded-For: 8.8.8.$i" \
-X POST -d "username=ghost&password=foo" \
https://your.host/admin/login
done- Rate-limit state is per-process and resets on restart. Adequate at the deployment scale this project targets; for cluster setups push the limit into the reverse proxy or a shared Redis.
audit_logdoes not yet persistip_addressfor theauth.login/auth.registerrows surfaced via the CLI/web UI; the IP is captured intousers.last_login_ip/users.registration_ipbut the action-level history shows-.- CSRF cookie is scoped to
Path=/admin; the same cookie is therefore not visible to/api/*. JSON API clients must use Bearer JWT, which is unaffected.