Skip to content

Server Auth & Security Boundary

dazeb edited this page Sep 17, 2026 · 2 revisions

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.

Module map

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.

Trust model: fail-closed boot token

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: createApp falls back to createAuthPolicy() 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.

Credential channels and comparison

authorizeUpgrade(policy, authHeader?, urlToken?) is the decision function:

  1. If policy.disabled, return true (the disclosed mode makes any presentation valid, including none).
  2. Otherwise take the first available credential: extractBearer(header) or the ?token= query parameter. Empty → reject.
  3. 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.

Delivering the token to the browser

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.html is served with two script tags injected after <head>: /termsprawl-boot.js first, then /termsprawl-shim.js. The shim reads the global and stores the token in localStorage for 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 to tokenFromUrl.

Hosted-space router gate (defense in depth)

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.

Call chain

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"]
Loading

Key nodes:

  • assertSafeServerBind runs in the entrypoint before createApp, using the default 127.0.0.1 host. A non-loopback bind is allowed only when TERMSPRAWL_SPACE_HEADER is 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 AuthPolicy through createApp({ auth }), which is why createApp itself does not depend on environment variables.
  • Static serving resolves every request through resolveContainedPath(RENDERER_DIR, ...) and requires a regular file. Only index.html is 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 the clients set 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.

What sits behind the token: scoping and redaction

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 reads result.error — tests assert on that shape.
  • Settings redaction. redactSettings strips provider key material and the Telegram bot token while keeping provider ids, hasKey flags, enabled, and allowedChatIds intact. The app:settings-get RPC 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.

Key state and invariants

  • AuthPolicy { token, disabled } — immutable for the lifetime of the process; disabled is true only via an explicit empty token.
  • clients: Set<WebSocket> — registry of accepted sockets; entries are removed on close and error.
  • window.__TERMPRAWL_WS_TOKEN — the client-side copy of the token, then persisted to localStorage by the shim.
  • TERMSPRAWL_SPACE_HEADER — its presence switches on the router gate; its absence keeps plain Server Edition behavior.
  • createApp().authToken — the token string, or null in disclosed mode, printed once by the entrypoint.
  • createApp().dispatch — the single RPC dispatcher shared by boot-time calls and WS traffic.

Boundary conditions

  • 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 same timingSafeCompare.
  • Tests never bind a listener. Listening, the bind guard, and token logging only run under TERMSPRAWL_SERVER_ENTRY === '1', so createApp stays 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.

Regression expectations

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: true and accepts credential-less upgrades;
  • comparison does not throw on malformed input;
  • redactSettings and the app:settings-get dispatcher result never contain sk-live-... key material or the Telegram token;
  • file:read inside a known project succeeds, while an arbitrary outside path is refused via the result's error field.

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.

Extension points

  • Inject a policy: createApp({ auth }) accepts a custom AuthPolicy for 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 timingSafeCompare rather than hand-rolling a comparison.
  • Trust a new deployment topology: extend the isLoopbackHost / assertSafeServerBind contract so the "safe to serve the token" reasoning stays explicit.
  • Observe requests: the onRequest option on createApp receives every method name; the entrypoint uses it to mark the space-sync pusher dirty for MUTATING_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.ts when the handler touches a new resource class.

Sources:

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally