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. 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.promises.realpath() and re-validates the real path against allowed prefixes.
Gateway token exfiltrationToken only returned via /api/connect-defaults to loopback clients. Remote clients receive null.
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 has no user accounts or login system. Access is controlled at the network level (localhost binding, firewall, VPN).
  • 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 does not implement its own auth layer. Security is enforced through:

  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 only exposed to loopback clients via /api/connect-defaults.
  4. Session storage — The frontend stores the gateway token in sessionStorage (cleared when the tab closes), not localStorage.

When exposing Nerve to a network (HOST=0.0.0.0), consider:

  • Using a VPN (Tailscale, WireGuard) — the setup wizard has first-class Tailscale support
  • Placing Nerve behind a reverse proxy with authentication (nginx + basic auth, OAuth proxy, etc.)
  • Restricting access with firewall rules

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:

env
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; includeSubDomains (production only)Enforce HTTPS for 1 year
Referrer-Policystrict-origin-when-cross-originControl referrer leakage
Cache-Controlno-storeDefault for responses without an existing Cache-Control header (overridden by cache middleware for assets)

Content Security Policy

default-src 'self';
script-src 'self';
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-ancestors 'none';
base-uri 'self';
form-action 'self'

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.promises.realpath(). 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):

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


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 sent in production mode (NODE_ENV=production) with 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)Only returned to loopback clients via /api/connect-defaults. 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 token (client)sessionStorageCleared when browser tab closes. Not persisted to disk.

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
Session storageGateway token stored in sessionStorage, not localStorage — cleared on tab close
CSP enforcementscript-src 'self' blocks inline scripts and external script injection
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.