Skip to content

fix(server): security review fixes for the self-hosted server - #29

Merged
HackTuah merged 13 commits into
mainfrom
fix/sec-server
Oct 7, 2026
Merged

HackTuah merged 13 commits into
mainfrom
fix/sec-server

Conversation

@HackTuah

@HackTuah HackTuah commented Oct 7, 2026

Copy link
Copy Markdown
Member

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.

  • B-01 (medium, server half): by default the server listens on both 127.0.0.1 and [::1], so another local account can't take the IPv6 loopback and receive the token. It also adds POST /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 gets EADDRINUSE, and the proof matches an independent openssl HMAC.
  • B-09 / C-09: a weak API_TOKEN gets a startup warning. After 10 wrong tokens in a minute, an address gets 429 with Retry-After; other addresses aren't affected (lead replay: ten 401s, then 429).
  • B-02: the server refuses to start when the data folder or its files belong to another user, when Kotiko's files are symlinks, or when the folder is writable by others and holds other files. api-token is read without following links.
  • B-03: the old Slovo copy is made 0600 after migration and at every start, and --delete-data removes it.
  • B-04: the refused-Host log is capped per hour, with one summary line.
  • B-05: a user name or password inside LLM_URL, TRANSCRIBE_URL or PUBLIC_URL is refused and never logged.
  • B-06: a startup warning when a provider key would go over plain HTTP to a host that is neither this computer nor Tailscale.
  • B-07: at debug level, the model's words are logged only with 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]:PORT is 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

Ayla Croft and others added 12 commits October 6, 2026 21:31
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>
@HackTuah
HackTuah merged commit 5308e05 into main Oct 7, 2026
21 of 22 checks passed
@HackTuah
HackTuah deleted the fix/sec-server branch October 7, 2026 11:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant