Nerve is designed as a local-first web UI for an AI agent. Its security model assumes the server runs on a trusted machine and is accessed by its owner. It is not designed for multi-tenant or public-internet deployment without an additional reverse proxy and authentication layer.
- Threat Model
- Authentication & Access Control
- CORS Policy
- Security Headers
- Rate Limiting
- Input Validation
- File Serving Security
- WebSocket Proxy Security
- Body Size Limits
- Path Traversal Prevention
- TLS / HTTPS
- Token & Secret Handling
- Client-Side Security
- Configuration File Security
- Reporting Vulnerabilities
| Threat | Mitigation |
|---|---|
| Cross-site request forgery (CSRF) | CORS allowlist restricts cross-origin requests. Only explicitly configured origins are allowed. |
| Cross-site scripting (XSS) | CSP script-src 'self' blocks inline/injected scripts (exception: s3.tradingview.com for chart widgets). HTML content is sanitised with DOMPurify on the client. |
| Clickjacking | X-Frame-Options: DENY and CSP frame-ancestors 'none' prevent embedding in iframes. |
| Network sniffing | Optional HTTPS with HSTS (max-age=31536000; includeSubDomains). |
| Abuse / resource exhaustion | Per-IP rate limiting on all API endpoints. Global body size limits. Rate limit store capped at 10,000 entries. |
| Directory traversal | Resolved absolute paths checked against strict prefix allowlists. Symlinks resolved and re-checked. |
| Symlink escape | /api/files resolves symlinks via fs.realpathSync() and re-validates the real path against allowed prefixes. |
| Gateway token exfiltration | Token is never sent to the client; it is injected server-side specifically for trusted (local/authenticated) connections. |
| Spoofed client IPs | Rate limiter uses the real TCP socket address. X-Forwarded-For only trusted from configured TRUSTED_PROXIES. |
| MIME sniffing | X-Content-Type-Options: nosniff on all responses. |
| CSP directive injection | CSP_CONNECT_EXTRA is sanitised: semicolons and newlines stripped, only http(s):// and ws(s):// schemes accepted. |
| Malformed CORS origins | ALLOWED_ORIGINS entries are normalised via new URL(). Malformed entries and "null" origins are silently rejected. |
- Multi-user authentication — Nerve supports single-user password authentication. Multi-user accounts with roles are not yet implemented.
- End-to-end encryption — TLS covers transport; at-rest encryption of memory files or session data is not provided.
- DDoS protection — The in-memory rate limiter handles casual abuse but is not designed for sustained attacks. Use a reverse proxy (nginx, Cloudflare) for production exposure.
Nerve includes a built-in session-cookie-based authentication layer that can be enabled via the NERVE_AUTH environment variable.
Security is enforced through network-level controls:
- Localhost binding — The server binds to
127.0.0.1by default. Only local processes can connect. - CORS allowlist — Browsers enforce the Origin check. Only configured origins receive CORS headers.
- Gateway token isolation — The sensitive
GATEWAY_TOKENis never sent to the browser. Instead, Nerve injects it server-side into the WebSocket connection upgrade for trusted clients. - Client config persistence — The frontend stores the gateway URL and optional manual token in
localStorageasoc-config. Trusted official-gateway flows usually keep the token empty because server-side injection handles auth.
All API endpoints (except /api/auth/* and /health) require a valid session cookie. WebSocket upgrade requests are also checked.
How it works:
- User submits a password via
POST /api/auth/login - The server verifies the password against a stored scrypt hash (or accepts the gateway token as a fallback)
- On success, a signed
HttpOnlysession cookie is set - All subsequent requests include the cookie automatically
- The session token is a stateless HMAC-SHA256 signed payload containing only an expiry timestamp
Session cookie security:
| Property | Value | Purpose |
|---|---|---|
HttpOnly |
true |
Not accessible via JavaScript (XSS-proof) |
SameSite |
Strict |
Not sent on cross-origin requests (CSRF-proof) |
Secure |
auto | Only sent over HTTPS when HTTPS is active |
| Signed | HMAC-SHA256 | Tamper-proof — requires NERVE_SESSION_SECRET |
| Cookie name | nerve_session_{PORT} |
Port-suffixed to avoid collisions across instances |
Password storage:
- Passwords are hashed with scrypt (32-byte random salt, 64-byte derived key)
- Timing-safe comparison prevents timing attacks
- Passwords are never stored in plaintext or logged
Gateway token fallback:
Users who haven't set a dedicated password can authenticate using their existing GATEWAY_TOKEN. This provides zero-config authentication when upgrading to network access.
When exposing Nerve to a network (HOST=0.0.0.0):
- Enable authentication — the setup wizard prompts for this automatically
- Using a VPN (Tailscale, WireGuard) adds an additional layer of security
- Placing Nerve behind a reverse proxy with HTTPS is recommended for production
- The gateway token can serve as an immediate fallback password
CORS is enforced on all requests via Hono's CORS middleware.
Default allowed origins (auto-configured):
http://localhost:{PORT}https://localhost:{SSL_PORT}http://127.0.0.1:{PORT}https://127.0.0.1:{SSL_PORT}
Additional origins via ALLOWED_ORIGINS env var (comma-separated). Each entry is normalised through the URL constructor:
ALLOWED_ORIGINS=http://100.64.0.5:3080,https://my-server.tailnet.ts.net:3443Allowed methods: GET, POST, PUT, DELETE, OPTIONS
Allowed headers: Content-Type, Authorization
Credentials: Enabled (credentials: true)
Requests with no Origin header (same-origin, non-browser) are allowed through.
Applied to every response via the securityHeaders middleware:
| Header | Value | Purpose |
|---|---|---|
Content-Security-Policy |
See below | Defense-in-depth against XSS |
X-Frame-Options |
DENY |
Prevent clickjacking |
X-Content-Type-Options |
nosniff |
Prevent MIME type sniffing |
X-XSS-Protection |
1; mode=block |
Legacy XSS filter for older browsers |
Strict-Transport-Security |
max-age=31536000; includeSubDomains |
Enforce HTTPS for 1 year |
Referrer-Policy |
strict-origin-when-cross-origin |
Control referrer leakage |
Cache-Control |
no-store |
Default for all responses (overridden by cache middleware for assets) |
Implementation note: CSP directives are built lazily on first request (not at module import time) to avoid race conditions with
dotenv/configload order. The computed directives are then cached for the lifetime of the process.
default-src 'self';
script-src 'self' https://s3.tradingview.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' ws://localhost:* wss://localhost:* http://localhost:* https://localhost:*
ws://127.0.0.1:* wss://127.0.0.1:* http://127.0.0.1:* https://127.0.0.1:*
[CSP_CONNECT_EXTRA];
img-src 'self' data: blob:;
media-src 'self' blob:;
frame-src https://s3.tradingview.com https://www.tradingview.com
https://www.tradingview-widget.com https://s.tradingview.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self'
TradingView domains: The script-src and frame-src entries for TradingView are required for the inline tv chart type, which uses TradingView's official widget embed script. The script injects iframes from multiple TradingView subdomains.
The connect-src directive can be extended via CSP_CONNECT_EXTRA (space-separated). Input is sanitised:
- Semicolons (
;) and newlines (\r,\n) are stripped to prevent directive injection - Only entries matching
http://,https://,ws://, orwss://schemes are accepted
In-memory sliding window rate limiter applied to all /api/* routes.
| Preset | Limit | Window | Applied To |
|---|---|---|---|
| TTS | 10 requests | 60 seconds | POST /api/tts |
| Transcribe | 30 requests | 60 seconds | POST /api/transcribe |
| General | 60 requests | 60 seconds | All other /api/* routes |
- Per-client, per-path — Each unique
clientIP:pathcombination gets its own sliding window. - Client identification — Uses the real TCP socket address from
getConnInfo(). Not spoofable via request headers. - Trusted proxies —
X-Forwarded-ForandX-Real-IPare only honoured when the direct connection comes from an IP inTRUSTED_PROXIES(default: loopback addresses only). Extend viaTRUSTED_PROXIESenv var. - Store cap — The rate limit store is capped at 10,000 entries to prevent memory amplification from spoofed IPs (when behind a trusted proxy). When full, the oldest entry is evicted.
- Cleanup — Expired timestamps are purged every 5 minutes.
Every response includes:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
When rate-limited (HTTP 429):
Retry-After: 42
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1708100060
All POST/PUT endpoints validate request bodies with Zod schemas:
| Endpoint | Validated Fields |
|---|---|
POST /api/tts |
text (1–5000 chars, non-empty), provider (enum), voice, model |
PUT /api/tts/config |
Strict key allowlist per section, string values only, max 2000 chars |
POST /api/transcribe |
File presence, size (≤12 MB), MIME type allowlist |
POST /api/agentlog |
Optional typed fields (ts, type, message, level, data) |
POST /api/memories |
text (1–10000 chars), section (≤200), category (enum), importance (0–1) |
PUT /api/memories/section |
title (1–200), content (≤50000), date (YYYY-MM-DD regex) |
DELETE /api/memories |
query (1–1000), type (enum), date (YYYY-MM-DD regex) |
PUT /api/workspace/:key |
content (string, ≤100 KB), key checked against strict allowlist |
POST /api/git-info/workdir |
sessionKey (non-empty), workdir (non-empty, validated against allowed base) |
Validation errors return HTTP 400 with the first Zod issue message as plain text or JSON.
The GET /api/files endpoint serves local image files with multiple layers of protection:
Only image files are served:
| Extension | MIME Type |
|---|---|
.png |
image/png |
.jpg, .jpeg |
image/jpeg |
.gif |
image/gif |
.webp |
image/webp |
.svg |
image/svg+xml |
.avif |
image/avif |
Non-image file types return 403 Not an allowed file type.
Files are only served from these directories:
| Prefix | Source |
|---|---|
/tmp |
Hardcoded |
~/.openclaw |
Derived from os.homedir() |
MEMORY_DIR |
From configuration |
The request path is resolved to an absolute path via path.resolve(), blocking .. traversal. The resolved path must start with one of the allowed prefixes (with a path separator check to prevent /tmp-evil matching /tmp).
After the prefix check passes, the file's real path is resolved via fs.realpathSync(). The real path is then re-checked against the same prefix allowlist. This prevents:
- Symlinks inside
/tmppointing to/etc/passwd - Symlinks inside
~/.openclawpointing outside the allowed tree
If the real path falls outside allowed prefixes → 403 Access denied.
The ~ prefix in input paths is expanded to os.homedir() before resolution, preventing home directory confusion.
The WebSocket proxy (connecting the frontend to the OpenClaw gateway) restricts target hostnames:
Default allowed hosts: localhost, 127.0.0.1, ::1
Extend via WS_ALLOWED_HOSTS env var (comma-separated):
WS_ALLOWED_HOSTS=my-server.tailnet.ts.net,100.64.0.5This prevents the proxy from being used to connect to arbitrary external hosts.
Nerve performs server-side token injection to provide a zero-config connection experience for local and authenticated users without exposing the GATEWAY_TOKEN to the browser storage.
Injection Logic:
GET /api/connect-defaultsreturns the official gateway WebSocket URL,token: null, and aserverSideAuthflag.- The WebSocket proxy only injects
GATEWAY_TOKENwhen all of these are true:- a gateway token is configured on the server
- the request is trusted (
loopbackaccess or an authenticated session) - the WebSocket upgrade
Originis allowed
- For the official gateway URL, the browser connects with an empty token when
serverSideAuth=true. - Custom gateway URLs or untrusted contexts still require manual token entry in the connect dialog.
This keeps the managed gateway token on the server while still allowing explicit manual credentials for unsupported or custom connection paths.
OpenClaw 2026.2.19+ requires a signed device identity (Ed25519 keypair) for WebSocket connections to receive operator.read / operator.write scopes. Plain token authentication alone grants zero scopes.
Nerve generates a persistent device identity on first start (stored at ~/.nerve/device-identity.json) and injects it into the connect handshake. The gateway always stays on loopback (127.0.0.1) — Nerve proxies all external connections through its WS proxy.
First-time pairing (required once):
- Start Nerve and open the UI in a browser
- The first connection creates a pending pairing request on the gateway
- Approve it:
openclaw devices list→openclaw devices approve <requestId> - All subsequent connections are automatically authenticated
If the device is rejected (e.g. after a gateway reset), the proxy falls back to token-only auth. The connection succeeds but with reduced scopes — chat and tool calls may fail with "missing scope" errors. Re-approve the device to restore full functionality.
Architecture: Browser (remote) → Nerve (0.0.0.0:3080) → WS proxy → Gateway (127.0.0.1:18789). The gateway never needs to bind to LAN or be directly network-accessible.
| Scope | Limit | Enforced By |
|---|---|---|
Global (/api/*) |
~13 MB (12 MB + 1 MB overhead) | Hono bodyLimit middleware |
| TTS text | 5,000 characters | Zod schema |
| Transcription file | 12 MB | Application check |
| Agent log entry | 64 KB | Config constant |
| Workspace file write | 100 KB | Application check |
| Memory text | 10,000 characters | Zod schema |
| Memory section content | 50,000 characters | Zod schema |
| TTS config field | 2,000 characters | Application check |
Exceeding the global body limit returns 413 Request body too large.
Multiple layers prevent directory traversal attacks:
| Route | Mechanism |
|---|---|
/api/files |
path.resolve() + prefix allowlist + symlink resolution + re-check |
/api/memories (date params) |
Regex validation: /^\d{4}-\d{2}-\d{2}$/ — prevents injection in file paths |
/api/workspace/:key |
Strict key→filename allowlist (soul→SOUL.md, etc.) — no user-controlled paths |
/api/git-info/workdir |
Resolved path checked against allowed base directory (derived from git worktrees or WORKSPACE_ROOT). Exact match or child-path check with separator guard |
Nerve automatically starts an HTTPS server alongside HTTP when certificates are present:
certs/cert.pem # X.509 certificate
certs/key.pem # RSA/EC private key
HSTS is always sent (max-age=31536000; includeSubDomains), even over HTTP. Browsers that have previously visited over HTTPS will refuse HTTP connections for 1 year.
Microphone access requires a secure context. On
localhostHTTP works, but network access requires HTTPS.
| Secret | Storage | Exposure |
|---|---|---|
GATEWAY_TOKEN |
.env file (chmod 600) |
Used server-side for trusted official-gateway connections. /api/connect-defaults returns token: null. Never logged. |
OPENAI_API_KEY |
.env file |
Used server-side only. Never sent to clients. |
REPLICATE_API_TOKEN |
.env file |
Used server-side only. Never sent to clients. |
| Gateway URL + optional manual token | localStorage (oc-config) |
Used for reconnects. Trusted official-gateway flows usually keep the token empty; manually entered custom-gateway tokens persist until cleared. |
The setup wizard applies chmod 600 to .env and backup files, restricting read access to the file owner.
| Measure | Details |
|---|---|
| DOMPurify | All rendered HTML (agent messages, markdown) passes through DOMPurify with a strict tag/attribute allowlist |
| Local storage | Connection preferences are stored in localStorage as oc-config. The official managed gateway path can keep the token empty; manually entered custom tokens may persist until cleared |
| CSP enforcement | script-src 'self' https://s3.tradingview.com blocks inline scripts and limits external scripts to TradingView chart widgets only |
| No eval | No use of eval(), Function(), or innerHTML with unsanitised content |
The setup wizard:
- Writes
.envatomically (via temp file + rename) - Applies
chmod 600to.envand backup files - Cleans up
.env.tmpon interruption (Ctrl+C handler) - Backs up existing
.envbefore overwriting (timestamped.env.bak.*)
If you find a security issue, please open a GitHub issue or contact the maintainers directly. Do not disclose vulnerabilities publicly before they are addressed.