Security7 of 10
The web gate
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
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:
| Path | Public | Why |
|---|---|---|
/health | yes | liveness |
/gate, /gate/token | yes | this is where a browser with no credential gets one |
/api/mcp/oauth/callback | yes | an external service redirects here |
/favicon.ico | yes | a browser asks for it on the login page, unasked |
/login and the frontend's static assets | yes | needed before a credential exists |
/v1, /v1/chat/completions | yes to this gate | it carries its own bearer — see below |
/api/* | no | |
/app, /setup | no | a signed session, or a redirect to the login page |
/docs, /openapi.json | no | the 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.
The session cookie
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:
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:3000passes. - No
Originat 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
- Set
KOTOBA_WEB_PASSWORDon both sides. - If you use the ElevenLabs agent path, set
KOTOBA_API_KEYtoo. - Add your real origin to
CORS_ORIGINS. - 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.
