Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ Conventional Commits by release-please.

## Unreleased

- Release process: the check that holds store uploads until the security review is closed
could miss a "Gate: open" line written with a lookalike letter from another alphabet, an
invisible character or a fullwidth colon, and read the review as closed. It now reads
each line as a person would, keeps the gate open for any such line, and says which line.

- Security: the server warns when it would send an API key over plain HTTP to another
machine, but missed an address written in capitals, such as `HTTP://203.0.113.7/v1`. It
now warns however the address is written. An OpenRouter address in capitals is also
recognised as OpenRouter.

- Security: a Kotiko server set to a model of its own (`LLM_MODEL`), for example on a paid
provider, sent no limit on how long an answer may be, so a model that kept writing could
cost you up to the provider's own limit for one word. Every lookup now asks for at most
1,200 tokens of answer (4,000 for refreshing pronunciations), like the extension does,
in the field OpenAI expects when `LLM_URL` is OpenAI's.

- Security: a web page could read your word list. It could hide a long list of words on
the page, where you'd never see them, and read back which ones Kotiko swapped. Kotiko
now swaps only words you can see: text a page hides isn't touched, and text below the
Expand Down
5 changes: 4 additions & 1 deletion docs/stores.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,10 @@ Gate: closed 2026-11-02 by <lead>, fixes confirmed on v1.0.0-rc.2

The candidate it names is one of the reviewed version's. A second `Gate:` line (for example
`Gate: open`) above the appendix keeps the gate open, and lines under the appendix count for
nothing. That blocks every store upload until the first review is done. When a review is
nothing. Lines are read as a reader sees them: a gate line written with a lookalike letter
from another alphabet, an invisible character or a fullwidth colon still counts as a second
gate line, and the closing line itself must be plain ASCII (security review D-05). When the
gate stays open, the job says why for each report. That blocks every store upload until the first review is done. When a review is
opened for the version being released (a `review-vX.Y.Z-rc.N.md` for that version), that
review must be the closed one. Whether a later release needs a review at all (it touches
the areas above) is enforced by the checklist box, not by the workflow, because no script
Expand Down
94 changes: 81 additions & 13 deletions scripts/check-security-gate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@
// below the appendix count for nothing: a reviewer's report may quote the phrase (security
// review C-05).
//
// "One gate line" is judged as a reader sees the text, not byte by byte (security review
// D-05: an open line in a Cyrillic letter, behind a zero-width space or with a fullwidth
// colon sat unseen beside the closed one). Each line is NFKC-normalised, stripped of
// invisible characters (format and default-ignorable code points) and of combining marks;
// then a line whose first four characters, after any leading markup, are g, a, t, e (any
// case) or a character outside ASCII, not followed by a letter or digit, reads as a gate
// line. Any non-ASCII character stands in for any letter, so lookalikes from every
// script count without a confusables table, and a colon-like character after the word
// counts too. That is stricter than a reader (a stray "G芒t茅:" line keeps the gate open,
// with a message saying which line), which is the safe side. The closed line itself must
// still be the exact ASCII form.
//
// This check passes when the tagged tree has a closed report. With --version X.Y.Z (the
// release workflow passes the tag's), a review opened for that version must itself be
// closed; a version without its own review is gated by the earlier ones. The release
Expand All @@ -32,21 +44,52 @@ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
export const REPORT_RE = /^review-v(\d+\.\d+\.\d+)(?:-rc\.\d+)?\.md$/;
export const CLOSED_RE = /^Gate: closed (\d{4}-\d{2}-\d{2}) by (.+), fixes confirmed on (v\d+\.\d+\.\d+-rc\.\d+)$/;
export const APPENDIX_RE = /^## Appendix\b/;
// Any line that reads as a gate line, however it is dressed up, so that only one may exist.
const ANY_GATE_RE = /^[\s>*_\-+#]*gate[*_\s]*:/i;
// Any line that reads as a gate line, however it is dressed up, so that only one may exist:
// after `visible`, leading markup or punctuation, then four characters that are each the
// letter of "gate" or not ASCII, and no letter or digit right after (D-05).
const ANY_GATE_RE = /^[\s\p{P}>+]*[g\P{ASCII}][a\P{ASCII}][t\P{ASCII}][e\P{ASCII}](?![\p{L}\p{N}])/iu;

/** A line as a reader sees it: NFKC, without invisible characters or combining marks. */
const visible = (line) =>
line
.normalize("NFKC")
.replace(/[\p{Cf}\p{DI}]/gu, "")
.normalize("NFD")
.replace(/\p{M}/gu, "")
.normalize("NFC");
const isGateLine = (line) => ANY_GATE_RE.test(visible(line));

const realDate = (d) => !Number.isNaN(Date.parse(`${d}T00:00:00Z`)) && new Date(`${d}T00:00:00Z`).toISOString().startsWith(d);

/** The lead's closing line in a report's text, or null while the gate is open. */
export function gateLine(text) {
/** `{ gate }` (date, lead, candidate) when the report's gate is closed, else `{ problem }`. */
function readGate(text) {
const lines = text.split(/\r?\n/);
const end = lines.findIndex((l) => APPENDIX_RE.test(l));
if (end < 0) return null;
const gates = lines.slice(0, end).filter((l) => ANY_GATE_RE.test(l));
if (gates.length !== 1) return null;
const m = gates[0].match(CLOSED_RE);
if (!m || !realDate(m[1])) return null;
return { date: m[1], by: m[2], candidate: m[3] };
if (end < 0) return { problem: 'no "## Appendix" heading' };
const gates = lines.slice(0, end).flatMap((l, i) => (isGateLine(l) ? [i + 1] : []));
if (gates.length === 0) return { problem: 'no "Gate:" line above the appendix' };
if (gates.length > 1) {
return { problem: `${gates.length} lines above the appendix read as a gate line (lines ${gates.join(", ")}); keep only the one` };
}
const [n] = gates;
const line = lines[n - 1];
if (/[^\x20-\x7E]/.test(line)) {
return { problem: `line ${n} reads as a gate line but has characters outside plain ASCII (a lookalike letter, an invisible or fullwidth character); write it in plain ASCII` };
}
const m = line.match(CLOSED_RE);
if (!m) return { problem: `line ${n} is not the exact closed line` };
if (!realDate(m[1])) return { problem: `line ${n}: ${m[1]} is not a date` };
return { gate: { date: m[1], by: m[2], candidate: m[3] } };
}

/** The lead's closing line in a report's text, or null while the gate is open. */
export function gateLine(text) {
return readGate(text).gate ?? null;
}

/** Why a report's gate is open (one sentence), or null when its closing line is in order. */
export function gateProblem(text) {
return readGate(text).problem ?? null;
}

const core = (tag) => tag.replace(/^v/, "").replace(/-rc\.\d+$/, "");
Expand All @@ -60,16 +103,40 @@ export function closedReviews(dir, { version } = {}) {
if (e.code === "ENOENT") return [];
throw e;
}
return reviews(dir, names, version)
.filter((r) => !r.problem)
.map((r) => r.name);
}

/** Every report the release would read, each with why its gate is open (or no problem). */
function reviews(dir, names, version) {
let reports = names.filter((n) => REPORT_RE.test(n)).sort();
if (version && reports.some((n) => n.match(REPORT_RE)[1] === version)) {
reports = reports.filter((n) => n.match(REPORT_RE)[1] === version);
}
return reports.filter((n) => {
const line = gateLine(readFileSync(join(dir, n), "utf8"));
return line !== null && core(line.candidate) === n.match(REPORT_RE)[1];
return reports.map((name) => {
const { gate, problem } = readGate(readFileSync(join(dir, name), "utf8"));
const reviewed = name.match(REPORT_RE)[1];
if (problem) return { name, problem };
if (core(gate.candidate) !== reviewed) return { name, problem: `${gate.candidate} is not a candidate of ${reviewed}` };
return { name, problem: null };
});
}

/** Why each report's gate is open, as "file: reason" lines. */
export function openReasons(dir, { version } = {}) {
let names;
try {
names = readdirSync(dir);
} catch (e) {
if (e.code === "ENOENT") return [];
throw e;
}
return reviews(dir, names, version)
.filter((r) => r.problem)
.map((r) => `${r.name}: ${r.problem}`);
}

if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const arg = (name) => {
const i = process.argv.indexOf(name);
Expand All @@ -91,6 +158,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
"\"Gate: closed YYYY-MM-DD by <lead>, fixes confirmed on vX.Y.Z-rc.N\"" +
(version ? `, and if ${core(version)} has its own review, that one closed.` : "."),
);
for (const reason of openReasons(dir, { version: version && core(version) })) console.error(` ${reason}`);
process.exitCode = 1;
}
}
18 changes: 12 additions & 6 deletions server/lib/kotiko/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -550,10 +550,14 @@ defmodule Kotiko.Config do

@doc "Whether the model API is OpenRouter (it has the built-in free models)."
def openrouter?(url) do
# URI.parse/1 lowercases the scheme but not the host, which is case-insensitive too.
case URI.parse(url).host do
"openrouter.ai" -> true
host when is_binary(host) -> String.ends_with?(host, ".openrouter.ai")
_ -> false
host when is_binary(host) ->
host = String.downcase(host)
host == "openrouter.ai" or String.ends_with?(host, ".openrouter.ai")

_ ->
false
end
end

Expand Down Expand Up @@ -648,10 +652,12 @@ defmodule Kotiko.Config do
# between can read it. This computer and Tailscale (encrypted) are fine.
defp plain_http(_key_var, nil, _url_var, _url), do: []

defp plain_http(key_var, _key, url_var, {:ok, "http://" <> _ = url}) do
host = URI.parse(url).host
# The scheme from URI.parse/1, which lowercases it: a scheme is case-insensitive, so
# HTTP://... goes out in the clear just the same (security review D-04).
defp plain_http(key_var, _key, url_var, {:ok, url}) when is_binary(url) do
%URI{scheme: scheme, host: host} = URI.parse(url)

if Kotiko.Exposure.network(host) in [:loopback, :tailscale] do
if scheme != "http" or Kotiko.Exposure.network(host) in [:loopback, :tailscale] do
[]
else
[
Expand Down
31 changes: 29 additions & 2 deletions server/lib/kotiko/llm/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ defmodule Kotiko.LLM.Client do
@doc """
Asks `model` once. `caps` is the model's `%{json_mode, reasoning_toggle, max_tokens}`;
`opts`: `:timeout_ms` (required), `:json` (send `response_format`, default
`caps.json_mode`), `:max_tokens` (sent when `caps.max_tokens`).
`caps.json_mode`), `:max_tokens` (the output cap, sent on every request in the field
`max_tokens_field/0` names).
"""
def attempt(model, caps, messages, opts) do
timeout = Keyword.fetch!(opts, :timeout_ms)
Expand All @@ -29,7 +30,10 @@ defmodule Kotiko.LLM.Client do
|> put_if(json?, :response_format, %{type: "json_object"})
# Thinking takes 5-20 s longer and doesn't get these lookups more right.
|> put_if(openrouter?() and caps.reasoning_toggle, :reasoning, %{enabled: false})
|> put_if(caps.max_tokens and is_integer(opts[:max_tokens]), :max_tokens, opts[:max_tokens])
# The spec's output cap on every request, whatever the catalog says, so a model on a
# paid endpoint that keeps writing can't bill the key up to the provider's own limit
# (security review D-03). OpenRouter ignores it for a model that doesn't take it.
|> put_if(is_integer(opts[:max_tokens]), max_tokens_field(), opts[:max_tokens])

request = fn ->
Req.post(
Expand Down Expand Up @@ -72,6 +76,29 @@ defmodule Kotiko.LLM.Client do
@doc "Whether LLM_URL is OpenRouter (its catalog, quota and app headers)."
def openrouter?, do: Kotiko.Config.openrouter?(Application.fetch_env!(:kotiko, :llm_url))

@doc """
The request field for the output cap: the `maxTokensField` of the spec/providers.json
preset on LLM_URL's host (OpenAI's reasoning models take only `max_completion_tokens`),
`max_tokens` otherwise.
"""
def max_tokens_field do
host = host(Application.fetch_env!(:kotiko, :llm_url))

field =
Enum.find_value(Spec.providers(), "max_tokens", fn p ->
host && is_binary(p["baseUrl"]) && host(p["baseUrl"]) == host && p["maxTokensField"]
end)

if field == "max_completion_tokens", do: :max_completion_tokens, else: :max_tokens
end

defp host(url) do
case URI.parse(url).host do
host when is_binary(host) and host != "" -> String.downcase(host)
_ -> nil
end
end

@doc "Whether an API key is configured."
def key?, do: Application.get_env(:kotiko, :llm_api_key) not in [nil, ""]

Expand Down
10 changes: 9 additions & 1 deletion server/lib/kotiko/spec.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ defmodule Kotiko.Spec do

@root Path.expand("../../../spec", __DIR__)

@json_files ~w(languages lang-aliases rules pronunciation models wiktionary)
@json_files ~w(languages lang-aliases rules pronunciation models wiktionary providers)
for name <- @json_files, do: @external_resource(Path.join(@root, name <> ".json"))
@external_resource Path.join(@root, "prompt.md")
@external_resource Path.join(@root, "VERSION")
Expand Down Expand Up @@ -73,6 +73,7 @@ defmodule Kotiko.Spec do
@pronunciation read_json.(Path.join(@root, "pronunciation.json"))
@models read_json.(Path.join(@root, "models.json"))
@wiktionary read_json.(Path.join(@root, "wiktionary.json"))
@providers read_json.(Path.join(@root, "providers.json"))
@prompt_text File.read!(Path.join(@root, "prompt.md"))
@version @root |> Path.join("VERSION") |> File.read!() |> String.trim()
@lang_data lang_data
Expand Down Expand Up @@ -107,6 +108,13 @@ defmodule Kotiko.Spec do
@doc "spec/wiktionary.json: where pronunciations come from (slice 49 section 4a)."
def wiktionary, do: Map.delete(@wiktionary, "_comment")

@doc """
spec/providers.json (slice 11 section 4): the lookup presets. The server has no preset
setting, only LLM_URL; it uses a preset's request details (such as `maxTokensField`)
when LLM_URL is on that preset's host.
"""
def providers, do: @providers["providers"]

@doc "spec/prompt.md, unparsed."
def prompt_text, do: @prompt_text

Expand Down
38 changes: 38 additions & 0 deletions server/test/kotiko/config_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,9 @@ defmodule Kotiko.ConfigTest do
assert Config.openrouter?("https://eu.openrouter.ai/api/v1")
refute Config.openrouter?("http://localhost:11434/v1")
refute Config.openrouter?("https://notopenrouter.ai/v1")
# A host name is case-insensitive too (security review D-04's neighbour).
assert Config.openrouter?("HTTPS://OpenRouter.ai/api/v1")
assert Config.openrouter?("https://EU.OPENROUTER.AI/api/v1")
end

# B-09 (slice 54): any 24 characters were accepted, even 24 a's.
Expand Down Expand Up @@ -615,6 +618,41 @@ defmodule Kotiko.ConfigTest do
assert warning =~ "TRANSCRIBE_API_KEY is sent over plain HTTP to whisper.lan"
end

# D-04 (slice 54): a scheme is case-insensitive (RFC 3986 section 3.1) and Req sends
# HTTP://... in the clear, but only a lowercase http:// got the warning.
test "whatever the scheme's case" do
for url <- ~w(HTTP://203.0.113.7/v1 Http://203.0.113.7/v1 hTtP://203.0.113.7/v1) do
assert [warning] =
plain_http_warnings(%{
"LLM_URL" => url,
"LLM_MODEL" => "m",
"LLM_API_KEY" => "k"
}),
url

assert warning =~ "LLM_API_KEY is sent over plain HTTP to 203.0.113.7"
end

assert [_] =
plain_http_warnings(%{
"TRANSCRIBE_URL" => "HTTP://whisper.lan:8178/inference",
"TRANSCRIBE_API_KEY" => "k",
"LLM_API_KEY" => "k"
})

assert plain_http_warnings(%{
"LLM_URL" => "HTTPS://203.0.113.7/v1",
"LLM_MODEL" => "m",
"LLM_API_KEY" => "k"
}) == []

assert plain_http_warnings(%{
"LLM_URL" => "HTTP://LOCALHOST:11434/v1",
"LLM_MODEL" => "m",
"LLM_API_KEY" => "k"
}) == []
end

test "not to this machine, over Tailscale, over HTTPS, or without a key" do
for url <- ~w(http://127.0.0.1:11434/v1 http://localhost:11434/v1 http://[::1]:1/v1
http://ollama.localhost/v1 http://100.101.102.103/v1
Expand Down
Loading