Skip to content
tnsaiDocs
GitHub

Harden the server

TnsServer starts with settings for one person on one machine: it listens on loopback, asks for no token, accepts WebSocket connections only from loopback origins and indexes only its working directory. This page covers each protection and what to change before other people can reach the port.

Defaults

ProtectionDefaultBuilder methodFlag or environment variable
Bind address127.0.0.1bindPolicy--host, --allow-public, TNSAI_HOST, TNSAI_ALLOW_PUBLIC
Bearer tokennoneauthConfigTNSAI_TOKEN
WebSocket originsloopback hosts on any portoriginPolicyTNSAI_ALLOWED_ORIGINS
Session capabilitiesalways onnonenone
Workspace for indexingworking directory, 50,000 filesworkspaceConfigTNSAI_WORKSPACE_ROOT, TNSAI_WORKSPACE_MAX_FILES
Request deduplicationin-memory storeidempotencyStorenone

The flags and variables apply to the tnsai-server launcher; see Server. In code, set the same things on the builder.

Expose the server

static TnsServer publicServer(Supplier<LLMClient> llm, Path projects, IdempotencyStore idempotency) {
    return TnsServer.builder()
        .bindPolicy(new BindPolicy("0.0.0.0", true))                 // allowPublic opts in to a non-loopback host
        .authConfig(AuthConfig.withToken(System.getenv(AuthConfig.TOKEN_ENV)))
        .originPolicy(OriginPolicy.strict("https://app.example.com"))
        .workspaceConfig(WorkspaceConfig.roots(projects).withMaxFileCount(20_000))
        .idempotencyStore(idempotency)                               // shared replay state across instances
        .llmClientSupplier(llm)
        .build();                                                    // throws on a public host without a token
}

Two checks run when the server is built:

  • A host other than 127.0.0.1, ::1, 0:0:0:0:0:0:0:1 or localhost needs allowPublic set to true. Otherwise build() throws IllegalStateException.
  • A host other than loopback also needs a token. Without one, build() throws IllegalStateException.

The server speaks plain HTTP and WebSocket. For TLS, put a reverse proxy in front of it.

Require a token

With a token set, every HTTP request and every WebSocket upgrade must carry Authorization: Bearer <token>. A missing or wrong token gets 401 with the code UNAUTHENTICATED. The comparison takes the same time whatever the token's content.

  • AuthConfig.withToken throws IllegalArgumentException for a blank token. AuthConfig.disabled() is the default.
  • Only /health, /health/live and /health/ready are open without a token. Point probes at those paths. The aliases /healthz and /readyz need the token like any other route.
  • A browser cannot set headers on a WebSocket. A token-protected server therefore needs a non-browser client, or a proxy that adds the header.

Allow browser origins

Browsers send an Origin header with every WebSocket handshake. The server checks it at the upgrade and answers a rejected origin with 403 and the code FORBIDDEN_ORIGIN. HTTP routes do not check the origin.

PolicyAccepts
OriginPolicy.loopback()localhost, 127.0.0.1, [::1] and ::1 on any port
OriginPolicy.with(origins...)The loopback hosts plus the listed origins
OriginPolicy.strict(origins...)Only the listed origins
OriginPolicy.fromEnv()The loopback hosts plus the comma-separated list in TNSAI_ALLOWED_ORIGINS

Every policy accepts a handshake with no Origin, a blank one or the value null, so non-browser clients pass. Listed origins match exactly, ignoring case, for example https://app.example.com.

Session capabilities

A session belongs to the first client that touches it. On that first request the server mints a random token and returns it in the X-Session-Capability response header. Every later request for the same session must present it, or it gets 403 with the code FORBIDDEN_SESSION.

WhereHow the token is sent
POST /api/index, GET /api/index/status, POST /api/search, DELETE /api/index, GET /api/auditX-Session-Capability header
WebSocket upgrade, /chat?sessionId=<id>&capability=<token>capability query parameter or the header
  • Tokens live in memory. A restart clears them, along with the sessions.
  • On a WebSocket upgrade, the capability is checked when the URL carries a sessionId.
  • Keep the bearer token on whenever other people can reach the port.

Limit the workspace

POST /api/index indexes a folder the client names. Before indexing, the server:

  1. Resolves the path, following symlinks. For a path that does not exist yet, it resolves the parent folder.
  2. Checks that the result is inside one of the workspace roots.
  3. Counts the files under it and stops at the limit.

A path outside every root, or a folder with more files than the limit, gets 400 with the code OUTSIDE_WORKSPACE.

WorkspaceConfig.cwd() is the default. WorkspaceConfig.roots(paths...) sets the roots and withMaxFileCount(n) sets the limit. WorkspaceConfig.fromEnv() reads TNSAI_WORKSPACE_ROOT, a list separated by the platform's path separator (: or ;), and TNSAI_WORKSPACE_MAX_FILES; an invalid number is ignored.

Deduplicate retried requests

A client that sends Idempotency-Key on a POST, PUT or PATCH gets each response once, however often it retries. Requests without the header are not affected.

CaseResponse
First request with a keyRuns normally. A 2xx response is stored for 24 hours.
Same key, same bodyThe stored response
Same key, different body409, code IDEMPOTENCY_KEY_REUSED
Same key while the first request still runsWaits up to 30 seconds for its response, then 409, code IDEMPOTENCY_KEY_IN_FLIGHT
Blank key, or longer than 256 characters400, code IDEMPOTENCY_KEY_INVALID

Keys are scoped to the bearer token, the method and the path, so two clients with different tokens cannot see each other's responses. The store keeps a hash of the request body, never the body itself. A response other than 2xx is not stored, so a retry runs the request again.

The default store lives in the process. Behind a load balancer, pass a shared store such as RedisIdempotencyStore or PostgresIdempotencyStore; see Idempotency.

Before you expose the server

  1. Set a long random TNSAI_TOKEN and keep it out of source control.
  2. Bind the host you need with --allow-public.
  3. Put a TLS proxy in front of the port.
  4. List the browser origins that may connect, or use OriginPolicy.strict for no loopback origins.
  5. Narrow the workspace roots to the folders clients may index.
  6. Point health probes at /health/live and /health/ready.
  7. Use a shared idempotency store if you run more than one instance.