# 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**:

1. **Bind policy** — listener binds to loopback unless explicitly opted out.
2. **Bearer auth** — every HTTP request and WS upgrade requires a token.
3. **Origin allowlist** — the WS upgrade rejects unrecognised browser origins.
4. **Per-session capability tokens** — only the client that created a session
   can drive it.
5. **Workspace allowlist** — `/api/index` accepts 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:

```bash
# 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.

```bash
TNSAI_TOKEN=$(openssl rand -hex 32) \
  java -jar tnsai-server.jar
```

When `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 `null` Origin (native non-browser clients).
- `http://localhost[:port]`, `https://localhost[:port]`, and the `127.0.0.1`
  / `::1` variants.

Any other Origin gets `403 FORBIDDEN_ORIGIN` at upgrade time. Add explicit
production origins via:

```bash
TNSAI_ALLOWED_ORIGINS="https://app.tnsai.dev,https://staging.tnsai.dev" \
  java -jar tnsai-server.jar
```

The 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

```bash
# 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:

```text
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:

```bash
# 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.jar
```

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

```java
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=true` carve-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](sandbox.md) — isolated execution primitive used by tools that
  agents drive after the listener has authenticated their request.
- [Approvals and Annotations](approvals-and-annotations.md) — per-action
  approval gates, applied after auth + capability admit the request.
- [Cost Governance](cost-governance.md) — `CostBudget` per tenant / agent,
  applied after auth identifies who the request belongs to.
