Repository navigation
fix(server): security review fixes for the self-hosted server - #29
Merged
Merged
Conversation
The limiter on "Refused a request for host" lines cleared its table once it held 1,000 names, so the same names logged again and any client could write as many lines as it liked (slice 54, B-04). The new Kotiko.Log.Limiter keeps one line per name per hour; when 1,000 names have been logged in an hour it logs one line saying new names aren't logged until the hour ends, and how many went unlogged when the next hour starts. Regression test: 3 rounds of the same 1,001 names now give at most 1,001 lines and one summary (it gave 3,003 lines before). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
Browsers look up `localhost` themselves and try ::1 first, but the server listened only on 127.0.0.1. Another account on the computer could listen on [::1]:4747 and receive the token the extension sends to http://localhost:4747, even while Kotiko was running (slice 54, B-01). Kotiko.Listener now starts one listener per address: a loopback BIND (the default 127.0.0.1, localhost or ::1) also holds the other loopback address, 0.0.0.0 also holds ::1, and :: is dual-stack. BIND=localhost is always 127.0.0.1 plus ::1, whatever the hosts file says. Without IPv6 the server starts on 127.0.0.1 alone and logs a line; another program already listening on either address stops the start with status 1 and a message naming it. Regression tests: with the default settings a second listener on [::1]:<port> now fails with eaddrinuse and the server answers there (before, the squatter's listen succeeded); a squatter already on [::1] stops the start. Tests that need ::1 are tagged :ipv6 and skipped on a machine without it. Docs tell users to enter http://127.0.0.1:4747. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
Nothing limited wrong-token attempts, so a client could guess a weak
API_TOKEN at thousands of tries a second (slice 54, B-09 and C-09). After 10
wrong Bearer tokens from one address within a minute (an IPv6 /64 counts as
one address), Kotiko.AuthThrottle answers 429 rate_limited
(details.reason auth_failures) with Retry-After to every request from that
address that needs the token, the right one included, until the minute is
over. Other addresses are never affected, requests without a token don't
count, and the lockout is logged once an hour per address. Counters live in
Kotiko.RateLimit, an ETS table capped at 10,000 entries.
For B-01's defence in depth, POST /api/v1/proof (no token needed) answers
{"proof": base64url(HMAC-SHA256(api_token, "kotiko-proof-v1:" <> nonce))},
without padding, for a nonce of 32 to 128 base64url characters. The
extension can check that an address holds the token before it sends it
there. At most 30 proofs a minute per address; only this exact path and
method are open; the body is capped at 1,000 bytes.
Regression tests: the 11th wrong token from one address now gets 429 while
the right token from another address gets 200 (before, every attempt got
401); the proof route answers the documented test vector. The listener
test is no longer async, so its log line can't land in another test's
capture_log.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
Any API_TOKEN of 24 characters was accepted without a word, even 24 a's or "password" three times (slice 54, B-09). Kotiko.Token.weakness/1 flags a token with fewer than 10 different characters, one that repeats a shorter piece, or one under 64 bits by its characters' frequencies (length times the Shannon entropy of the counts). Config warns about such an API_TOKEN (or API_TOKEN_FILE), and the boot warns about such a token saved in api-token by hand. The warning never shows the token. This matters more now that POST /api/v1/proof lets someone test guesses offline. Regression tests: 24 a's and "password" x3 now start with a warning (they started with none); 200 generated tokens and openssl hex get no warning; a boot with a weak saved token warns without printing it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
…IC_URL A user name and password in LLM_URL (https://user:pass@host/v1) were accepted and written to the log at info level in the startup summary's "Model:" line (slice 54, B-05). Config now refuses userinfo in LLM_URL, TRANSCRIBE_URL and PUBLIC_URL with a message that hides the value and says where the key goes (LLM_API_KEY, TRANSCRIBE_API_KEY or their _FILE settings). An invalid address containing an @ isn't echoed either. As defence in depth the startup summary strips userinfo from the model URL. Regression tests: config refuses such URLs without echoing the password (before, they started with no warning); the summary's model line shows the URL without credentials (before, it showed them). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
The server warned when its own token crossed a network in the clear, but not when LLM_API_KEY or TRANSCRIBE_API_KEY was sent to a plain http:// address on another machine (slice 54, B-06). Config now warns at start when a key is set and its URL is http:// to a host that isn't this computer (localhost, *.localhost, 127.0.0.0/8, ::1) or Tailscale (100.64.0.0/10, fd7a:115c:a1e0::/48, *.ts.net). Kotiko.Exposure.network/1 classifies the URL's host; the warning names the key and the host, never the key's value. Regression tests: an LLM key to http://203.0.113.7 and a transcription key to http://whisper.lan now warn (they didn't); loopback, Tailscale, https and no key stay quiet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
At LOG_LEVEL=debug the words the model answered and the server refused (native and gloss) were logged even with LOG_LOOKUPS=false, though the docs say words are never logged without it (slice 54, B-07). The same was true of a changeset that couldn't be saved and of the words a language-tag upgrade flags. Each of those debug lines now checks Kotiko.LLM.log_lookups?/0 (now public); without it only the reasons are logged. Regression tests (capture_log at debug): the legacy add with a refused word, a save error and the language-tag upgrade no longer log the words without LOG_LOOKUPS (they did), and still do with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
…es in Someone who could write in the data folder could choose the API token (the server used whatever api-token it found, whoever owned it) and could put a kotiko.db link there that sent every word to a world-readable file of their choosing, with no warning, because the permission check skips links (slice 54, B-02). At every start, before anything in the folder is read or written, Kotiko.DataDir.check_safe/2 now stops the server (status 78) with a message naming each problem and how to fix it when: the folder or one of Kotiko's files belongs to another uid; kotiko.db, its -wal/-shm/-journal, api-token, the model cache, an old slovo.db, backups/ or a backup is a symbolic link (Kotiko never makes links, and judging where one points would mean trusting it, so none is accepted; KOTIKO_DATA_DIR itself may still be a link); or others can write in a folder that also holds other files. A folder others can write in that holds only Kotiko's files is made 0700 first, with a warning, so nobody can swap a file afterwards. api-token is read with Kotiko.Private.read_own/3: lstat, open, fstat and compare device and inode, which stands in for O_NOFOLLOW (the BEAM has none), and only a file of the server's own uid. The uid comes from /proc/self, or a probe file; no command runs. Regression tests: reviewer B's planted folder (0777, a planted api-token, kotiko.db linked to a 0666 file elsewhere, another file) now stops the boot with status 78 and nothing is written through the link (before, the server started with the planted token and wrote the words there); a linked api-token is refused by Token.resolve (before, it was followed). The docs no longer say others with a shared folder see only file names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
…th the data After the one-time copy from the folder used before the rename, the old database (0644 when a 0.2 install made it with umask 022) and the old api-token, which is the live token too, stayed as they were, and `install-service.sh --uninstall --delete-data` didn't remove them, so other accounts could still read every word (slice 54, B-03). Kotiko.DataDir.make_legacy_private/1 now makes the old database, the files SQLite keeps next to it and the old api-token 0600 in a folder the words were copied from (it holds MOVED-TO-KOTIKO.txt), right after the copy and at every start, so installs copied before this fix are fixed too. --delete-data also deletes that old copy, its token and its note, in the old default folder and in a data folder that was migrated in place, but only when the note names the data folder being deleted. Regression tests: after the copy the old database and token are 0600 (they stayed 0644); a boot from a 0644 old folder leaves it 0600; --delete-data removes the old folder whose note names this data folder (it was left behind) and keeps one whose words went elsewhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
Sobelow 0.16 finds nothing in check_safe/2, its helpers or read_own/3 without skips, so they go. find_uid/0 keeps one: it deletes a probe file it named itself in the temporary folder (TMPDIR, from the owner's environment), never a path from a request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
101 addresses each sending 10 wrong tokens give 100 lockout lines and one line saying new ones aren't logged until the hour is over (slice 54, B-09). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
Both were reviewed for slice 54's server fixes (the proof route, the wrong-token throttle, the listeners on both loopback addresses and the data folder check). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
This was referenced Oct 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Server fixes from the pre-release security review (slice 54, SCR-476). Each fix has an ExUnit regression test that failed before it, and the lead replayed the reviewers' proofs against a live server from this branch.
127.0.0.1and[::1], so another local account can't take the IPv6 loopback and receive the token. It also addsPOST /api/v1/proof, which proves the server holds the token without revealing it (an HMAC of a nonce), so the extension can check before it sends the token. Lead replay: the squatter now getsEADDRINUSE, and the proof matches an independent openssl HMAC.API_TOKENgets a startup warning. After 10 wrong tokens in a minute, an address gets429withRetry-After; other addresses aren't affected (lead replay: ten 401s, then 429).api-tokenis read without following links.--delete-dataremoves it.LLM_URL,TRANSCRIBE_URLorPUBLIC_URLis refused and never logged.LOG_LOOKUPS=true(the migration's suspect-word log included).Self-hosters may notice that the server now refuses to start on a shared-writable folder, on a WSL Windows drive, as root over a user's folder, or when
[::1]:PORTis already taken. Each refusal comes with a message saying how to fix it, and all are in the CHANGELOG and docs.Checks: format, credo strict, warnings as errors, 1,163 tests and 23 properties with 0 failures, 91.6 % coverage, Sobelow clean, ShellCheck, the site tests.
🤖 Generated with Claude Code