Server Hardening
tnsai-server exposes the framework over HTTP + WebSocket. Every input — HTTP body, WS frame, query param — is potentially attacker-controlled if the listener is reachable. The hardening surface in com.tnsai.server.security (TNS-302) is built on five concentric defences, every one of which is
enabled by default:
- Bind policy — listener binds to loopback unless explicitly opted out.
- Bearer auth — every HTTP request and WS upgrade requires a token.
- Origin allowlist — the WS upgrade rejects unrecognised browser origins.
- Per-session capability tokens — only the client that created a session can drive it.
- Workspace allowlist —
/api/indexaccepts only paths under the configured workspace roots, with a file-count cap.
The five layers are independent. A misconfiguration of any one (e.g. you forget to set a Bearer token) does not collapse the others.
Bind policy
Default: 127.0.0.1 (loopback). Opt in to a non-loopback bind explicitly:
# Loopback (default — no flag)
java -jar tnsai-server.jar
# Bind to all interfaces
java -jar tnsai-server.jar --host 0.0.0.0 --allow-public
# Bind to a specific interface
java -jar tnsai-server.jar --host 192.168.1.10 --allow-public| Source | Variable / flag |
|---|---|
| CLI | --host <addr> / --allow-public |
| Env | TNSAI_HOST=<addr> / TNSAI_ALLOW_PUBLIC=true |
Without --allow-public, a non-loopback host crashes the JVM at startup with
IllegalStateException. This is intentional — the default refuses to make
anything reachable beyond the host machine.
A non-loopback bind also requires a Bearer token (next section); starting
with --allow-public and no TNSAI_TOKEN is rejected.
Bearer authentication
Every HTTP request and every WS upgrade must carry
Authorization: Bearer <TNSAI_TOKEN> when a token is configured. Missing or
mismatched tokens return 401. Health probes (/health, /health/live,
/health/ready) are explicitly exempt so orchestrators can probe without a
secret.
TNSAI_TOKEN=$(openssl rand -hex 32) \
java -jar tnsai-server.jarWhen TNSAI_TOKEN is unset, auth is disabled — but only valid combined
with the loopback bind. The constructor of TnsServer rejects the
non-loopback + no-token combination.
Token comparison is constant-time over equal-length values. Length itself is not secret, so length-mismatch shortcuts; value-byte timing is the part that matters.
Origin allowlist (WebSocket)
Browsers attach Origin: <scheme://host[:port]> on every cross-origin WS
handshake. The default OriginPolicy.loopback() admits:
- Missing or
nullOrigin (native non-browser clients). http://localhost[:port],https://localhost[:port], and the127.0.0.1/::1variants.
Any other Origin gets 403 FORBIDDEN_ORIGIN at upgrade time. Add explicit
production origins via:
TNSAI_ALLOWED_ORIGINS="https://app.tnsai.dev,https://staging.tnsai.dev" \
java -jar tnsai-server.jarThe matcher is case-insensitive and not prefix-greedy: localhost.evil.com
is rejected even though it has localhost as a substring.
Per-session capability tokens
The first request to touch a sessionId mints a random 32-byte URL-safe
token. The server returns it via the X-Session-Capability response header
(HTTP) or via the WS upgrade response. Every subsequent request with the
same sessionId must present the same token; otherwise 403 FORBIDDEN_SESSION.
This is the layer that prevents one client on the same listener from attaching to another client's session and reading their tool outputs.
HTTP transport
# 1. First call — server mints a capability and echoes it.
curl -i -X POST http://127.0.0.1:7777/api/index \
-H "Authorization: Bearer $TNSAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"sessionId":"alice-1","path":"./project"}'
# < X-Session-Capability: AbCdEf...
# 2. Subsequent calls present that capability.
curl -X POST http://127.0.0.1:7777/api/search \
-H "Authorization: Bearer $TNSAI_TOKEN" \
-H "X-Session-Capability: AbCdEf..." \
-H "Content-Type: application/json" \
-d '{"sessionId":"alice-1","query":"hello"}'WebSocket transport
The capability is supplied as a query string on the upgrade URL:
ws://127.0.0.1:7777/chat?sessionId=alice-1&capability=AbCdEf...The first WS upgrade for a sessionId mints; the response carries
X-Session-Capability. Subsequent upgrades must echo the same value or get
403. After upgrade, the connection is bound to its sessionId — frames
that try to drive a different sessionId on the same socket are rejected
with FORBIDDEN_SESSION.
Tokens are in-memory; a server restart invalidates every capability, which
matches the in-memory nature of SessionManager itself.
Workspace allowlist
/api/index accepts a directory path from the request body. Without an
allowlist, that endpoint is a "give me your disk" gadget — a hostile client
can POST /api/index {"path":"/"} then POST /api/search to exfiltrate
contents.
The default WorkspaceConfig.cwd() allows only paths under the JVM's
working directory. Configure additional roots:
# Multiple roots (PATH-separator)
TNSAI_WORKSPACE_ROOT="/srv/projects/foo:/srv/projects/bar" \
java -jar tnsai-server.jar
# Lower the file-count cap
TNSAI_WORKSPACE_MAX_FILES=10000 \
java -jar tnsai-server.jarEach request path is canonicalised via Path.toRealPath() before the
allowlist check. Symlinks that escape the workspace are resolved and then
rejected — there is no way to sneak content in via a child of an allowed
directory whose ancestor is a symlink to /etc.
/api/index also rejects 400 OUTSIDE_WORKSPACE when the directory tree
exceeds maxFileCount (default 50,000). The walk short-circuits on the
first count past the cap, so the rejection is constant-time in the size of
the offending tree.
Programmatic configuration
If you embed TnsServer in another process, every layer is composable on
the builder:
TnsServer server = TnsServer.builder()
.port(7777)
.bindPolicy(new BindPolicy("0.0.0.0", true))
.authConfig(AuthConfig.withToken(System.getenv("TNSAI_TOKEN")))
.originPolicy(OriginPolicy.with(
"https://app.tnsai.dev",
"https://staging.tnsai.dev"))
.workspaceConfig(WorkspaceConfig.roots(
Path.of("/srv/projects/foo"),
Path.of("/srv/projects/bar")).withMaxFileCount(20_000))
.llmClientSupplier(...)
.build();
server.start();Acceptance reference
The five reject paths covered by the integration test:
| Reject case | Status | Code |
|---|---|---|
| HTTP without Bearer token | 401 | UNAUTHENTICATED |
| HTTP with mismatched session capability | 403 | FORBIDDEN_SESSION |
/api/index with path outside workspace | 400 | OUTSIDE_WORKSPACE |
| WS upgrade with disallowed Origin | 403 | FORBIDDEN_ORIGIN |
Non-loopback bind without --allow-public | startup IllegalStateException | — |
What's not in v1
- Capability rotation — tokens live for the lifetime of the server. Rotation hooks are a follow-up issue.
- Admin role — every capability owns its own session and only its own
session. There is no
/api/audit?all=truecarve-out yet. - Token persistence — capabilities are in-memory; a server restart invalidates every session.
- CIDR allowlist on the bind side — the policy is binary loopback / public; finer-grained network ACLs are out of scope.
See also
- Sandbox — isolated execution primitive used by tools that agents drive after the listener has authenticated their request.
- Approvals and Annotations — per-action approval gates, applied after auth + capability admit the request.
- Cost Governance —
CostBudgetper tenant / agent, applied after auth identifies who the request belongs to.