Kotoba

Where to start

What Kotoba isWho she is and where she runs, in one page
InstallingOne package, two commands
First runA key, a model, her first words
The approval gateHow she asks before she acts
The two voice modesLocal voice, or the agent tunnel
The soul fileChange who she is
RoadmapWhat grows next, and what was cut on purpose

Or a section

↑↓ move openesc close124 pages
All pages

Security7 of 10

The web gate

About 7 minutes to read

On this page

The backend serves an API that can run commands on your machine. With no password set, it is open. That is fine for a laptop bound to localhost. It is not fine for anything a network can reach.

One password, two names

bash
KOTOBA_WEB_PASSWORD=your-secret-word

Set it in both api/.env and .env.local, to the same value. KOTOBA_GATE_PASSWORD is an accepted alias on both sides, used only when KOTOBA_WEB_PASSWORD is unset; the canonical name always wins if both are set.

Once it is set, every /api/* route requires it.

How a caller proves it has the password

Three forms are accepted for /api/*:

  • Authorization: Bearer <password> — the normal one.
  • ?token=<password> — for browser surfaces that cannot set a header: the SSE stream, an <img>, a downloaded report.
  • A derived cookie, for the two viewers described below.

The query form is kept out of the logs. Uvicorn logs the query string verbatim, which wrote the password in clear text on every page load into a file that gets pasted into bug reports — so a filter rewrites token=… to token=<redacted> on the access and error loggers. The path still tells you what was requested.

Comparison is constant-time, on bytes, so a password with non-ASCII characters in it does not raise instead of returning 401.

What is public

The gate denies by default. These are the exceptions, measured with a password set:

PathPublicWhy
/healthyesliveness
/gate, /gate/tokenyesthis is where a browser with no credential gets one
/api/mcp/oauth/callbackyesan external service redirects here
/favicon.icoyesa browser asks for it on the login page, unasked
/login and the frontend's static assetsyesneeded before a credential exists
/v1, /v1/chat/completionsyes to this gateit carries its own bearer — see below
/api/*no
/app, /setupnoa signed session, or a redirect to the login page
/docs, /openapi.jsonnothe route map of an API that runs host commands

Nothing is matched by suffix, and nothing by an allow-list of prefixes to protect. Both shapes have failed open here before: an endswith rule for the OAuth callback also matched the catch-all file routes, and behind a proxy that keeps a path prefix, the router strips what the server prepends, so every route ran ungated. The public list is matched whole, and the frontend's half is derived from the build on disk rather than from a list somebody has to keep in step.

POST /gate trades the password for an HMAC-signed session cookie named kotoba_gate, valid for 12 hours. The payload is a signed issue time; verification checks the signature with compare_digest and then the expiry.

The cookie is HttpOnly, SameSite=Lax, path /, and Secure unless the request positively says http — a forwarded https from any proxy hop is enough. That is lopsided on purpose: a needlessly secure cookie is dropped by the browser in silence and the login loops forever with nothing to read.

The session cookie authenticates pages, not the API. It is accepted only on GET/HEAD for the app's own gated pages. Accepting it on /api/* would swap a header-carried token for an ambient, automatically-sent one on a surface that runs commands.

GET /gate/token hands the API token to a browser that already holds a valid session — how a refresh or a second tab re-arms itself without the password ever reaching the bundle. The body is the password, so the response carries Cache-Control: no-store, private. Without a valid session it answers 401; with the gate off it answers an empty token, which is what an open backend expects.

DELETE /gate signs you out, and Settings → Security shows a Sign out control when a password is configured.

KOTOBA_GATE_SECRET optionally replaces the password as the HMAC signing key, so you can invalidate every session without changing the password. It is not itself a password — on its own it does not enable the gate — and it must be set on both sides or neither: two halves signing with different keys means the login succeeds and every request after it refuses.

The two viewer cookies

Opening a file or a model asset in a browser tab is a request the browser makes with no header it can set. Rather than hand the password to the browser, the first hit carries ?token=, which is swapped for a narrow cookie and redirected to the same URL without the token — so the address bar and history never keep it.

  • kf — reading a page and its relative assets under /api/files/raw, nothing else.
  • km — reading the files a Live2D model is made of, nothing else.

Both are HMACs of the password over different messages, so neither can be replayed where the other is accepted, and neither is the password. Each is scoped to its own path prefix and checked by method.

An earlier version of kf was the password verbatim with SameSite=None — a 12-hour bearer for the entire control surface, handed to the browser on every file open.

The model endpoint

/v1/chat/completions is the OpenAI-compatible endpoint the ElevenLabs agent path calls. It has its own bearer:

bash
KOTOBA_API_KEY=pick-a-long-random-secret

It accepts either that key or the web password. So setting the web password alone closes both halves; you only need KOTOBA_API_KEY if you are using KOTOBA_VOICE_MODE=agent, where an external platform must call in.

With neither set, /v1 is open. It is deliberately excluded from the /api/* gate: the credential handed to a third-party voice platform must never double as a key to the whole control surface, and KOTOBA_API_KEY is correspondingly not accepted on /api/*.

CORS

The allowed origins are http://localhost:3000 and http://127.0.0.1:3000, always. CORS_ORIGINS adds to that list; it never replaces it. Measured: with CORS_ORIGINS=https://example.com, the effective list is the two localhost origins plus that one.

It is never *. This API executes commands, so a random website must not be able to drive it from a victim's browser.

The voice WebSocket

A WebSocket handshake is exempt from CORS, so the allow-list that protects every HTTP route does not protect it. /api/voice/{session_id} checks the Origin header itself:

  • An origin on the CORS list is allowed. Measured: http://localhost:3000 passes.
  • No Origin at all is allowed — a browser always sends one, so its absence means the caller is not a page: the terminal, a script, a test.
  • Otherwise, only a same-origin request on loopback passes. Matching origin against host alone would accept a rebound domain, where the attacker's page keeps its own origin while the name resolves to your machine. Measured: a foreign origin against a loopback host is refused with close code 4403.

With a password set, the socket also requires the token, by header or ?token=, and closes 4401 without it.

Note that the voice socket cannot go through a proxy — Next does not forward upgrades — so it dials the backend directly. That is why its address is configured separately.

Brute-force resistance

Twenty failed authentications from one client address return 429 instead of 401. The window is rolling, not fixed: each failure pushes the deadline 60 seconds past itself, so a steady trickle keeps the count alive indefinitely and the lockout ends 60 seconds after the last attempt.

A valid credential is checked before the lockout, deliberately: a loopback install shares 127.0.0.1 between everything, so refusing a correct token during a cooldown would lock the real frontend out of its own backend.

A browser asking for a gated page with no credential is redirected to the login screen and not counted — the lockout is shared with the login, so counting reloads would lock somebody out of the screen they were being sent to.

POST /gate carries its own defences, since it is public by necessity: the same lockout, plus a fixed delay so a wrong answer costs the same as a right one.

Response headers

Every HTTP response gets X-Content-Type-Options: nosniff. Any response whose media type could be treated as a document — which is everything except a short inert list of audio, font, video, JSON, CSS, JavaScript, PDF, WASM and event-stream types — also gets a sandbox CSP:

sandbox allow-scripts; default-src 'self'; img-src 'self' data:;
style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline';
connect-src 'none'; form-action 'none'; base-uri 'none'

Deny by default, because the two file routes used to attach this only on text/html, so an .svg or .xhtml — scriptable the moment you navigate to it, which the Files panel's "New tab" does — went out bare. Omitting allow-same-origin puts such a document on an opaque origin; connect-src 'none' closes fetch, XHR and beacon. The residue is stated rather than hidden: such a document can still navigate itself away, carrying nothing with it.

The app's own pages additionally carry frame-ancestors 'none', X-Frame-Options: DENY and Referrer-Policy: strict-origin-when-cross-origin.

Before you expose a port

  1. Set KOTOBA_WEB_PASSWORD on both sides.
  2. If you use the ElevenLabs agent path, set KOTOBA_API_KEY too.
  3. Add your real origin to CORS_ORIGINS.
  4. Terminate TLS in front of it, so the session cookie is Secure.

Exposing the backend to a network without a gate password is explicitly out of scope for a vulnerability report.