This document lists the current security mechanisms and boundaries used by ZenNotes.
It is a technical reference, not deployment advice. For deployment guidance, see Secure Self-Hosting.
ZenNotes currently aims at:
- single-user desktop use
- single-user self-hosted browser use
- desktop clients connecting to a trusted ZenNotes server
It does not currently claim to be a fully hardened public multi-user SaaS platform.
The long-lived server bootstrap secret is:
ZENNOTES_AUTH_TOKEN
When present:
- protected server routes require either a valid bearer token or a valid server session
The browser login flow uses:
POST /api/session/loginPOST /api/session/logoutPOST /api/session/rotate-tokenGET /api/session
Behavior:
- token is sent in the request body
- successful login creates a random session token
- the server sets a cookie:
HttpOnlySameSite=StrictPath=/api
- cookie is marked
Securewhen the request is effectively HTTPS
Current session TTL:
- 30 days
POST /api/session/rotate-token replaces the bootstrap auth token in
host config and invalidates all existing sessions when the token is
managed by ZenNotes' host config. Requires:
- a valid current session or bearer token (the route is auth-protected)
- the current token in the request body (defence-in-depth against CSRF-style misuse via stolen session)
- a new token at least 16 characters long
The new token is persisted with mode 0600 to the host config file.
Clients must re-login with the new token after rotation.
If the token is externally managed with ZENNOTES_AUTH_TOKEN or
ZENNOTES_AUTH_TOKEN_FILE, the endpoint returns 409 Conflict.
Update the env value or token file instead, then restart the server.
The browser should not depend on:
- URL token query params
- local storage copies of the server auth token
The current intended browser model is:
- bootstrap token once
- then session cookie
The server currently protects its vault/file operations behind auth middleware.
Examples include:
- vault selection
- directory browsing
- note CRUD
- folder CRUD
- assets
- watcher WebSocket
Public/meta routes include:
/api/healthz/api/version/api/capabilities/api/platform/api/session/api/session/login/api/session/logout
Current lightweight rate limiting exists for:
- login attempts
- unauthorized WebSocket attempts
Each subsequent attempt within the window also incurs an exponential backoff (0, 1, 2, 4, 8, 16, 32, 60s), so even the first few failures cost real time. Rate-limit state is in-memory only and resets on restart, but the bootstrap token's 256-bit entropy makes brute-force infeasible regardless.
The server validates request origins.
Current model:
- same-origin is allowed
- explicitly configured origins from
ZENNOTES_ALLOWED_ORIGINSare allowed - localhost/loopback origins are allowed in dev-like loopback scenarios
This is stricter than the previous permissive * model.
Rejected origins are logged once per unique origin in the form
CORS rejected origin "https://x.example.com"; add it to ZENNOTES_ALLOWED_ORIGINS to allow it, so misconfigured deployments
surface in operator logs instead of silently failing in the browser.
X-Forwarded-Proto, X-Forwarded-Host, and X-Forwarded-For are
honoured only when the immediate TCP peer is in the configured set.
Relevant config:
ZENNOTES_TRUSTED_PROXIES— comma-separated list of CIDRs (e.g.127.0.0.1/32,10.0.0.0/8) or bare IPs. When unset, no forwarded headers are trusted.
This affects:
- the
Secureflag on session cookies - the
Strict-Transport-Securityheader - rate-limit IP keying
Without a trusted-proxies list, an attacker reaching the server
directly cannot force-set Secure cookies or spoof rate-limit
identity by injecting headers.
The server sends browser security headers directly in HTTP responses.
Current headers include:
Content-Security-PolicyX-Content-Type-Options: nosniffReferrer-Policy: no-referrerPermissions-PolicyStrict-Transport-Security: max-age=63072000; includeSubDomains— sent only when the request was effectively HTTPS (real TLS, trustedX-Forwarded-Proto: https, orZENNOTES_BEHIND_TLS=1).
Important current CSP constraints:
default-src 'self'object-src 'none'base-uri 'none'form-action 'none'frame-ancestors 'none'
Important current CSP tradeoff:
script-srcstill includesunsafe-evalstyle-srcstill includesunsafe-inline
That is an acknowledged hardening gap, not an accidental omission.
The server treats browse roots as a real access-control boundary.
Relevant config:
ZENNOTES_BROWSE_ROOTSZENNOTES_ALLOW_UNSCOPED_BROWSE
Current behavior:
- requested browse/select paths are normalized
- symlinks are resolved
- the resolved path must stay within an allowed root unless unscoped browse is explicitly enabled
If no browse roots are configured, the server falls back to:
- current vault root
- default vault path
- configured vault path
depending on what exists.
User-supplied relative paths (note read/write/rename/delete, asset upload, folder ops) go through a symlink-aware resolver. Any existing path component that is a symlink is followed; if any of them resolves outside the vault root, the request is rejected with a path-escape error. This stops a planted in-vault symlink (host-level mistake or shared mount with surprises) from being used to read/write outside the vault.
Files created in the vault default to 0600; directories default to
0700. Override with:
ZENNOTES_VAULT_FILE_MODE(octal, e.g.0644)ZENNOTES_VAULT_DIR_MODE(octal, e.g.0755)
The defaults assume a single-user host where the vault is private to the running UID. Loosen them only if you intentionally share the vault with another local user.
ZENNOTES_MAX_NOTE_BYTES— default 10 MiB.POST /api/notes/writerejects bodies larger than this with413.ZENNOTES_MAX_ASSET_BYTES— default 50 MiB.POST /api/assets/uploadrejects multipart uploads above this with413.
These prevent an authenticated client (or stolen token) from filling the vault disk with a single request.
ZenNotes now separates host/server config from vault config.
Host/server operational config:
- lives in the host config file
- default path resolves from
ZENNOTES_CONFIG_PATHor the user config location
Vault config:
- belongs under
.zennotes/in the vault only for vault behavior
Important rule:
- server secrets should not be stored in the vault
Host config file writes currently use mode:
0600
Legacy behavior:
.zennotes/server.jsoninside the vault is treated as a legacy path and should not be used as the active secret store
Desktop remote workspace credentials are kept out of renderer-visible config.
Current storage order:
- OS secret store through
keytar, when available - Electron
safeStoragefallback
Important behavior:
- the fallback path stores encrypted values, not plaintext
- the app warns when secure storage is unavailable or when fallback storage is being used
Current desktop hardening includes:
contextIsolation: truenodeIntegration: false- IPC sender validation against trusted renderer URLs
- remote server traffic handled in the main process
Current limitation:
sandbox: false
That is a deliberate temporary tradeoff because the current preload path still depends on APIs that are not yet refactored for a fully sandboxed preload.
Current design goal:
- renderer should not receive raw remote secrets as normal profile data
The desktop app keeps remote API calls in the main process and stores credentials through the secret-store layer.
Current Docker defaults include:
- loopback-only published port
- non-root runtime user
- read-only root filesystem
/tmpastmpfsno-new-privilegescap_drop: ALL- generated auth token unless explicitly disabled
This is the default baseline for self-hosted browser/server deployment.
Important current variables:
ZENNOTES_AUTH_TOKENZENNOTES_AUTH_TOKEN_FILE— path to a file containing the token; used whenZENNOTES_AUTH_TOKENis unset, matching the Docker/Kubernetes secrets convention so the token never has to live in.env.ZENNOTES_CONFIG_PATHZENNOTES_BINDZENNOTES_ALLOWED_ORIGINSZENNOTES_BROWSE_ROOTSZENNOTES_VAULT_PATHZENNOTES_DEFAULT_VAULT_PATHZENNOTES_ALLOW_UNSCOPED_BROWSEZENNOTES_ALLOW_INSECURE_NOAUTHZENNOTES_BEHIND_TLS— declares a TLS-terminating proxy is in front; enablesSecurecookies andStrict-Transport-Security.ZENNOTES_TRUSTED_PROXIES— CIDR list whoseX-Forwarded-*headers are honoured.ZENNOTES_MAX_NOTE_BYTES— default 10 MiB.ZENNOTES_MAX_ASSET_BYTES— default 50 MiB.ZENNOTES_VAULT_FILE_MODE— octal mode for note files (default0600).ZENNOTES_VAULT_DIR_MODE— octal mode for note directories (default0700).
Docker/make wrappers also use:
CONTENT_ROOTPORTALLOW_INSECURE_NOAUTH