-
Notifications
You must be signed in to change notification settings - Fork 0
Server Auth & Security Boundary
Server Edition serves the built renderer to a browser and tunnels every window.termsprawl call over WebSocket RPC to the same Electron-free core services the desktop main process uses. That means the WS endpoint is a full RPC surface, not just a data feed. This page covers the layer that decides who may open that socket, and the surrounding boundary checks that make the rest of the RPC table safe to expose at all.
The design is deliberately small and pure: src/server/server-auth.ts has no Electron or HTTP dependency (only node:crypto), so the policy can be unit-tested in isolation, while src/server/index.ts wires it into the real HTTP/WS server and src/server/server-boundary.ts supplies bind-address and path-containment guards.
| File | Responsibility |
|---|---|
src/server/server-auth.ts |
AuthPolicy model, boot-token generation, timing-safe comparison, bearer/query credential extraction for the WS upgrade. Pure + electron-free. |
src/server/server-boundary.ts |
Loopback detection, refusal of unsafe non-loopback binds, and renderer asset path containment (resolveContainedPath). |
src/server/index.ts |
Boots the HTTP + WS server; delivers the token to same-origin pages via /termsprawl-boot.js; applies the hosted-space router gate; authorizes upgrades; owns the client registry and the single RPC dispatcher. |
src/server/security.test.ts |
The regression suite encoding audit finding B1: no connect without the boot token, and no arbitrary file read/write, git access outside known projects, or secret leakage through the RPC surface. |
src/server/shim.js |
Browser-side counterpart that reads the boot global and stores the token in localStorage for reconnects (detailed on the Renderer Shim & Server Boundary page). |
| Adjacent suites |
server-boundary.test.ts, server-boot-gate.test.ts, server.test.ts exercise the boundary and boot behavior around this module. |
The credential is a 48-hex-character token (24 random bytes rendered as hex). createAuthPolicy is the single constructor:
- An explicit token matching
/^[0-9a-f]{48}$/is honored as-is, which exists so scripted restarts can pin a known value. - Any other non-empty value — including malformed input — is discarded and replaced by a freshly generated token.
- The only way to disable auth is an explicitly supplied empty string (
TERMSPRAWL_SERVER_TOKEN=''). The absence of the environment variable does not disable auth: the boot path generates a token and prints it once for the operator. - Fail-closed is also enforced at the API level:
createAppfalls back tocreateAuthPolicy()when no policy is passed, so a caller who forgets to configure auth still gets a token requirement rather than an open server.
AuthPolicy is { token: string; disabled: boolean }, where disabled is true only in the disclosed empty-token mode. The entrypoint logs a loud warning when it sees that mode and createApp returns authToken: null instead of a token.
authorizeUpgrade(policy, authHeader?, urlToken?) is the decision function:
- If
policy.disabled, returntrue(the disclosed mode makes any presentation valid, including none). - Otherwise take the first available credential:
extractBearer(header)or the?token=query parameter. Empty → reject. - Compare with
timingSafeCompare.
extractBearer accepts Bearer <token> case-insensitively, but also returns the trimmed header unchanged if the prefix is absent — so the bare token is a valid Authorization value, as the tests assert. tokenFromRequest in the same module parses the upgrade URL into { header, urlToken } and swallows URL-parse errors by returning undefined, so malformed URLs can never throw out of the auth path.
timingSafeCompare hashes both sides with SHA-256 and then calls timingSafeEqual. Hashing first means unequal lengths do not leak length information, and the function never throws on malformed or multi-byte input. It is exported specifically so the HTTP layer can reuse it — the boot-JS route compares the router-presented space header with the same primitive.
Inline bootstrap scripts are impossible: the page's Content-Security-Policy allows only script-src 'self'. The token therefore cannot be embedded in HTML. Instead:
- The server renders
window.__TERMPRAWL_WS_TOKEN=<json>as/termsprawl-boot.js, a real same-origin script (the value is''when auth is disabled). -
index.htmlis served with two script tags injected after<head>:/termsprawl-boot.jsfirst, then/termsprawl-shim.js. The shim reads the global and stores the token inlocalStoragefor reconnects. - Because the shim reconnects can happen without a fresh page load, the WS upgrade also accepts the token as a
?token=query parameter — the "fallback auth channel for browsers" documented next totokenFromUrl.
Serving the token to any same-origin client is only safe if same-origin implies "authenticated by our router". In a hosted space the manager sets TERMSPRAWL_SPACE_HEADER; the router authenticates every request and re-injects a deterministic shared secret (sha256 hex of ${SPACE_JWT_SECRET}:${login}) as X-Termsprawl-Space. routerAuthenticated(req) requires that header to be a non-empty string matching the env value under timingSafeCompare. A direct hit on the app port — e.g. another tenant on a shared bridge — has no such header and gets 401, never the token.
When TERMSPRAWL_SPACE_HEADER is absent (plain offline Server Edition, localhost-only), the gate is inert and the token is served as usual.
The full path from process start to an accepted RPC frame:
flowchart TD
Start["process start: TERMSPRAWL_SERVER_ENTRY=1"] --> Bind{"assertSafeServerBind: loopback or space router?"}
Bind -- "no" --> Refuse["exit 1: refuse non-loopback bind"]
Bind -- "yes" --> Policy["createAuthPolicy(TERMSPRAWL_SERVER_TOKEN)"]
Policy --> Disclosed{"explicit empty token?"}
Disclosed -- "yes" --> Off["auth disabled (disclosed mode): log warning; authToken = null"]
Disclosed -- "no" --> On["48-hex token: honored or randomBytes(24)"]
Off --> App["createApp: HTTP + WS server + one RPC dispatcher"]
On --> App
App --> Static["GET / -> resolveContainedPath + isRegularFile -> index.html + boot/shim tags"]
Static --> BootReq["GET /termsprawl-boot.js"]
BootReq --> RouterGate{"TERMSPRAWL_SPACE_HEADER set?"}
RouterGate -- "yes" --> RouterAuth{"X-Termsprawl-Space matches (timing-safe)?"}
RouterGate -- "no" --> ServeBoot["serve window.__TERMPRAWL_WS_TOKEN"]
RouterAuth -- "no" --> Reject401["401: boot token never served"]
RouterAuth -- "yes" --> ServeBoot
ServeBoot --> Upgrade["WS upgrade request"]
Upgrade --> AuthGate{"authorizeUpgrade: disabled or token match?"}
AuthGate -- "yes" --> Accept["client registered; RPC frames -> dispatch"]
AuthGate -- "no" --> RejectUpgrade["upgrade rejected"]
Key nodes:
-
assertSafeServerBindruns in the entrypoint beforecreateApp, using the default127.0.0.1host. A non-loopback bind is allowed only whenTERMSPRAWL_SPACE_HEADERis present, i.e. when an authenticating router sits in front of the app port. Otherwise the process exits 1 with an explanatory error. -
Policy creation happens exactly once at boot. Tests can inject their own
AuthPolicythroughcreateApp({ auth }), which is whycreateAppitself does not depend on environment variables. -
Static serving resolves every request through
resolveContainedPath(RENDERER_DIR, ...)and requires a regular file. Onlyindex.htmlis served at/; the token never appears in the HTML, only in the separate boot script. - The router gate is checked before the boot token is emitted. This is the one place where a hosted-space request can be stopped before any credential is handed out.
-
The upgrade gate accepts
Authorization: Bearer <token>or?token=<token>and compares timing-safely. Accepted sockets are added to theclientsset used for event fan-out; rejected ones never reach the dispatcher. - RPC dispatch reuses the app's single dispatcher — the entrypoint is explicit that WS traffic and boot-time calls must share one instance so workspace-store revision maps do not desync.
Authentication answers "may this socket exist", not "what may it do". security.test.ts documents the contract for both:
-
File/git/PTY scoping. A shared core validator (used by both the desktop main process and the server) refuses file reads of arbitrary paths and restricts operations to known projects. A refusal is carried inside the result payload (
FileReadResult.error.code/message), not as an RPC envelope failure, because the renderer readsresult.error— tests assert on that shape. -
Settings redaction.
redactSettingsstrips provider key material and the Telegram bot token while keeping provider ids,hasKeyflags,enabled, andallowedChatIdsintact. Theapp:settings-getRPC returns the redacted form, so secrets never cross the WS boundary even for an authenticated client.
Beyond that, the HTTP layer defends itself without auth: serveStatic only reads regular files. A directory under out/renderer passes an existence check but throws EISDIR on read, and the code comments record that a single unauthenticated GET /assets was once enough to take the process down — which is why isRegularFile gates every response.
-
AuthPolicy { token, disabled }— immutable for the lifetime of the process;disabledis true only via an explicit empty token. -
clients: Set<WebSocket>— registry of accepted sockets; entries are removed oncloseanderror. -
window.__TERMPRAWL_WS_TOKEN— the client-side copy of the token, then persisted tolocalStorageby the shim. -
TERMSPRAWL_SPACE_HEADER— its presence switches on the router gate; its absence keeps plain Server Edition behavior. -
createApp().authToken— the token string, ornullin disclosed mode, printed once by the entrypoint. -
createApp().dispatch— the single RPC dispatcher shared by boot-time calls and WS traffic.
- Unset vs empty env var. Unset → generate and print a token (fail-closed). Empty string → auth disabled, warning logged. The distinction is explicit in both the module comment and the entrypoint.
-
Malformed explicit tokens are replaced, not rejected. A 47-hex or non-hex value silently falls through to
randomBytes(24), so an operator can never accidentally configure a weaker token shape. - Malformed presentations never throw. Unicode, odd lengths, missing headers, and unparseable URLs all resolve to a normal rejection.
-
?token=is an intentional second channel, needed because the shim reconnects after the initial page load; it is not a bypass — it goes through the sametimingSafeCompare. -
Tests never bind a listener. Listening, the bind guard, and token logging only run under
TERMSPRAWL_SERVER_ENTRY === '1', socreateAppstays importable for tests. - Shutdown closes the whole surface: dispose handlers, stop the agent bridge, close all clients, then the WS server, then the HTTP server.
- The boot-JS route is the only token egress. Even when the static layer serves other assets, only that route emits the token, and only after the router check when the gate is enabled.
The B1 findings are encoded as tests, not documentation. security.test.ts asserts:
- generated tokens match
^[0-9a-f]{48}$; - correct bearer, bare, and query credentials are accepted while wrong/missing/empty ones are rejected;
- an explicit empty token yields
disabled: trueand accepts credential-less upgrades; - comparison does not throw on malformed input;
-
redactSettingsand theapp:settings-getdispatcher result never containsk-live-...key material or the Telegram token; -
file:readinside a known project succeeds, while an arbitrary outside path is refused via the result'serrorfield.
Any change to the auth surface should extend this suite; server-boundary.test.ts and server-boot-gate.test.ts cover the adjacent bind and boot-gate behavior.
-
Inject a policy:
createApp({ auth })accepts a customAuthPolicyfor tests or alternative entrypoints. -
Add a credential channel: extend
extractBearer/tokenFromRequest/authorizeUpgrade— all three are pure and separately testable. -
Add an HTTP secret gate: reuse
timingSafeComparerather than hand-rolling a comparison. -
Trust a new deployment topology: extend the
isLoopbackHost/assertSafeServerBindcontract so the "safe to serve the token" reasoning stays explicit. -
Observe requests: the
onRequestoption oncreateAppreceives every method name; the entrypoint uses it to mark the space-sync pusher dirty forMUTATING_METHODS(workspace:save-nodes,project:add/import/delete/rename/close/archive/reopen/update-settings,terminal:close). -
Add RPC handlers: keep the redaction (settings) and scoping (files/git/PTY) discipline, and mirror
security.test.tswhen the handler touches a new resource class.
Sources:
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance