Skip to content

Security

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.


Table of Contents


Threat Model

In Scope

ThreatMitigation
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.
ClickjackingX-Frame-Options: DENY and CSP frame-ancestors 'none' prevent embedding in iframes.
Network sniffingOptional HTTPS with HSTS (max-age=31536000; includeSubDomains).
Abuse / resource exhaustionPer-IP rate limiting on all API endpoints. Global body size limits. Rate limit store capped at 10,000 entries.
Directory traversalResolved 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 exfiltrationToken is never sent to the client; it is injected server-side specifically for trusted (local/authenticated) connections.
Spoofed client IPsRate limiter uses the real TCP socket address. X-Forwarded-For only trusted from configured TRUSTED_PROXIES.
MIME sniffingX-Content-Type-Options: nosniff on all responses.
CSP directive injectionCSP_CONNECT_EXTRA is sanitised: semicolons and newlines stripped, only http(s):// and ws(s):// schemes accepted.
Malformed CORS originsALLOWED_ORIGINS entries are normalised via new URL(). Malformed entries and "null" origins are silently rejected.

Out of Scope

  • 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.

Authentication & Access Control

Nerve includes a built-in session-cookie-based authentication layer that can be enabled via the NERVE_AUTH environment variable.

When Auth is Disabled (default for localhost)

Security is enforced through network-level controls:

  1. Localhost binding — The server binds to 127.0.0.1 by default. Only local processes can connect.
  2. CORS allowlist — Browsers enforce the Origin check. Only configured origins receive CORS headers.
  3. Gateway token isolation — The sensitive GATEWAY_TOKEN is never sent to the browser. Instead, Nerve injects it server-side into the WebSocket connection upgrade for trusted clients.
  4. Client config persistence — The frontend stores the gateway URL and optional manual token in localStorage as oc-config. Trusted official-gateway flows usually keep the token empty because server-side injection handles auth.

When Auth is Enabled

All API endpoints (except /api/auth/* and /health) require a valid session cookie. WebSocket upgrade requests are also checked.

How it works:

  1. User submits a password via POST /api/auth/login
  2. The server verifies the password against a stored scrypt hash (or accepts the gateway token as a fallback)
  3. On success, a signed HttpOnly session cookie is set
  4. All subsequent requests include the cookie automatically
  5. The session token is a stateless HMAC-SHA256 signed payload containing only an expiry timestamp

Session cookie security:

PropertyValuePurpose
HttpOnlytrueNot accessible via JavaScript (XSS-proof)
SameSiteStrictNot sent on cross-origin requests (CSRF-proof)
SecureautoOnly sent over HTTPS when HTTPS is active
SignedHMAC-SHA256Tamper-proof — requires NERVE_SESSION_SECRET
Cookie namenerve_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.

Recommendations for 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 Policy

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:

bash
ALLOWED_ORIGINS=http://100.64.0.5:3080,https://my-server.tailnet.ts.net:3443

Allowed 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.


Security Headers

Applied to every response via the securityHeaders middleware:

HeaderValuePurpose
Content-Security-PolicySee belowDefense-in-depth against XSS
X-Frame-OptionsDENYPrevent clickjacking
X-Content-Type-OptionsnosniffPrevent MIME type sniffing
X-XSS-Protection1; mode=blockLegacy XSS filter for older browsers
Strict-Transport-Securitymax-age=31536000; includeSubDomainsEnforce HTTPS for 1 year
Referrer-Policystrict-origin-when-cross-originControl referrer leakage
Cache-Controlno-storeDefault for all responses (overridden by cache middleware for assets)

Content Security Policy

Implementation note: CSP directives are built lazily on first request (not at module import time) to avoid race conditions with dotenv/config load 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://, or wss:// schemes are accepted

Rate Limiting

In-memory sliding window rate limiter applied to all /api/* routes.

Presets

PresetLimitWindowApplied To
TTS10 requests60 secondsPOST /api/tts
Transcribe30 requests60 secondsPOST /api/transcribe
General60 requests60 secondsAll other /api/* routes

Implementation Details

  • Per-client, per-path — Each unique clientIP:path combination gets its own sliding window.
  • Client identification — Uses the real TCP socket address from getConnInfo(). Not spoofable via request headers.
  • Trusted proxiesX-Forwarded-For and X-Real-IP are only honoured when the direct connection comes from an IP in TRUSTED_PROXIES (default: loopback addresses only). Extend via TRUSTED_PROXIES env 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.

Response Headers

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

Input Validation

All POST/PUT endpoints validate request bodies with Zod schemas:

EndpointValidated Fields
POST /api/ttstext (1–5000 chars, non-empty), provider (enum), voice, model
PUT /api/tts/configStrict key allowlist per section, string values only, max 2000 chars
POST /api/transcribeFile presence, size (≤12 MB), MIME type allowlist
POST /api/agentlogOptional typed fields (ts, type, message, level, data)
POST /api/memoriestext (1–10000 chars), section (≤200), category (enum), importance (0–1)
PUT /api/memories/sectiontitle (1–200), content (≤50000), date (YYYY-MM-DD regex)
DELETE /api/memoriesquery (1–1000), type (enum), date (YYYY-MM-DD regex)
PUT /api/workspace/:keycontent (string, ≤100 KB), key checked against strict allowlist
POST /api/git-info/workdirsessionKey (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.


File Serving Security

The GET /api/files endpoint serves local image files with multiple layers of protection:

1. MIME Type Allowlist

Only image files are served:

ExtensionMIME Type
.pngimage/png
.jpg, .jpegimage/jpeg
.gifimage/gif
.webpimage/webp
.svgimage/svg+xml
.avifimage/avif

Non-image file types return 403 Not an allowed file type.

2. Directory Prefix Allowlist

Files are only served from these directories:

PrefixSource
/tmpHardcoded
~/.openclawDerived from os.homedir()
MEMORY_DIRFrom 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 /tmp pointing to /etc/passwd
  • Symlinks inside ~/.openclaw pointing outside the allowed tree

If the real path falls outside allowed prefixes → 403 Access denied.

4. Path Canonicalisation

The ~ prefix in input paths is expanded to os.homedir() before resolution, preventing home directory confusion.


WebSocket Proxy Security

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):

bash
WS_ALLOWED_HOSTS=my-server.tailnet.ts.net,100.64.0.5

This prevents the proxy from being used to connect to arbitrary external hosts.

Token Injection

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:

  1. GET /api/connect-defaults returns the official gateway WebSocket URL, token: null, and a serverSideAuth flag.
  2. The WebSocket proxy only injects GATEWAY_TOKEN when all of these are true:
    • a gateway token is configured on the server
    • the request is trusted (loopback access or an authenticated session)
    • the WebSocket upgrade Origin is allowed
  3. For the official gateway URL, the browser connects with an empty token when serverSideAuth=true.
  4. 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.

Device Identity & Gateway Scopes

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):

  1. Start Nerve and open the UI in a browser
  2. The first connection creates a pending pairing request on the gateway
  3. Approve it: openclaw devices listopenclaw devices approve <requestId>
  4. 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.


Body Size Limits

ScopeLimitEnforced By
Global (/api/*)~13 MB (12 MB + 1 MB overhead)Hono bodyLimit middleware
TTS text5,000 charactersZod schema
Transcription file12 MBApplication check
Agent log entry64 KBConfig constant
Workspace file write100 KBApplication check
Memory text10,000 charactersZod schema
Memory section content50,000 charactersZod schema
TTS config field2,000 charactersApplication check

Exceeding the global body limit returns 413 Request body too large.


Path Traversal Prevention

Multiple layers prevent directory traversal attacks:

RouteMechanism
/api/filespath.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/:keyStrict key→filename allowlist (soulSOUL.md, etc.) — no user-controlled paths
/api/git-info/workdirResolved path checked against allowed base directory (derived from git worktrees or WORKSPACE_ROOT). Exact match or child-path check with separator guard

TLS / HTTPS

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 localhost HTTP works, but network access requires HTTPS.


Token & Secret Handling

SecretStorageExposure
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 fileUsed server-side only. Never sent to clients.
REPLICATE_API_TOKEN.env fileUsed server-side only. Never sent to clients.
Gateway URL + optional manual tokenlocalStorage (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.


Client-Side Security

MeasureDetails
DOMPurifyAll rendered HTML (agent messages, markdown) passes through DOMPurify with a strict tag/attribute allowlist
Local storageConnection 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 enforcementscript-src 'self' https://s3.tradingview.com blocks inline scripts and limits external scripts to TradingView chart widgets only
No evalNo use of eval(), Function(), or innerHTML with unsanitised content

Configuration File Security

The setup wizard:

  1. Writes .env atomically (via temp file + rename)
  2. Applies chmod 600 to .env and backup files
  3. Cleans up .env.tmp on interruption (Ctrl+C handler)
  4. Backs up existing .env before overwriting (timestamped .env.bak.*)

Reporting Vulnerabilities

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.

Released under the MIT License.