Repository navigation
fix: sign every request instead of sending the server token; no local lockout (review D-01, D-02) - #35
Merged
Conversation
…(D-02) Slice 54, finding D-02 (reviewer D, rc.2). Every program on the computer, and any web page open in a browser there, reaches the server from 127.0.0.1. A page could send 35 no-cors POSTs to /api/v1/proof (no Content-Type, so they reached the counter) and use up the 30-a-minute proof limit, and any local program could send 10 wrong tokens and lock 127.0.0.1 out, the extension included. The proof route now refuses a request without Content-Type: application/json (415) and a malformed nonce (400) before counting, so only well-formed proofs count. This computer's own addresses (127.0.0.0/8, ::1, IPv4-mapped 127.x) are exempt from the proof limit and the wrong-token lockout; other addresses keep both. A generated token can't be guessed, and a weak chosen one is already warned about at start. Regression tests that fail without this: 31 malformed proofs then a JSON one gets 200; 10 wrong tokens from 127.0.0.1, ::1 or ::ffff:127.0.0.1 then the right one gets 200; a non-loopback address is still locked out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
Slice 54, finding D-01 (reviewer D, rc.2; it reopened B-01). The extension proved the server once per worker life and then sent the token with every request, so after the server stopped and another account took its port, the next sync handed that program the token. The server now accepts a request signed with the token instead of carrying it: Authorization: Kotiko-HMAC v1 ts=, nonce=, body=, mac=, where mac is HMAC-SHA256(token, "kotiko-req-v1\n" METHOD "\n" path?query "\n" ts "\n" nonce "\n" sha256-hex(body)). It compares the MAC in constant time, refuses a time more than 120 s off or from before the server started, a nonce it has seen (kept in ETS for the window, only after the MAC matched, at most 50,000; when full it refuses new signed requests rather than forget a live nonce), and a body whose hash isn't the signed one (read and checked before Plug.Parsers, which gets the same bytes through a caching body reader). A failed signature counts towards the lockout like a wrong Bearer token. Every answer to an accepted request, errors included, carries X-Kotiko-Server: v1 mac=HMAC(token, "kotiko-resp-v1\n" nonce "\n" status), so the client can tell its server's answers from anyone else's. A refused one says why in its challenge (Bearer, Kotiko-HMAC error="..."). Bearer stays for curl and tools. Tests: the documented vectors, a valid request and its signed answer, wrong MAC, token, path, method and query, stale and future times and one from before the start, a replayed nonce, a tampered body (nothing saved), a full nonce table, malformed headers, the lockout, signed 404/413/415/400, and every route the routers define. docs/reference/http-api.md describes the scheme with its test vectors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
Slice 54, finding D-01 (reviewer D, rc.2). With the proof cached for the
worker's life, a program that took the server's port after a crash or a
restart got the real token with the next sync (the lead reproduced three
requests carrying it) and, with an add, the learner's word.
The extension no longer sends the token anywhere. lib/server-auth.js signs
every request to the server with it (WebCrypto HMAC-SHA256 over the method,
the path and query as the server sees them, the time, 16 random bytes and
the body's SHA-256) and checks the server's X-Kotiko-Server signature on
every answer before reading it. An answer without it, or with a wrong one,
is never used: the server reads as not_kotiko_server, the proof is
forgotten, and nothing more is sent until a new proof succeeds, which a
squatter can't give. Anything but a GET (adds, edits, imports, deletes, the
profile) waits for a proof under 30 s old. The proof is also forgotten on
server.connect, an address or token change and any connection error. A
server from before signed requests answers them 401 with a plain Bearer
challenge, and the extension asks to update it; today's server refusing a
time more than two minutes off says so ("clock").
Regression tests that fail without this: loopback.spec.mjs replays D-01 in
Chromium (prove and sync, stop the server, a listener takes 127.0.0.1 and
::1 at its port, sync and add a word): before, the listener logged
"GET /kotiko/api/words auth=Bearer <token>" and the word's POST with the
token; now only the signed sync and a proof it couldn't give. Background
tests cover signing, the 30 s proof, forgetting the proof, and answers that
can't be trusted; unit tests check the documented vectors against a
separate node:crypto implementation. The test servers sign their answers.
The privacy policy (version 4) and inventory say the token isn't sent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
…case (D-01, D-02) Slice 54, findings D-01 and D-02. The assurance case now says the token never leaves the browser and what is left: a program that takes the port after the server stops gets no token and can't answer for the server; it can read the body of a write only if it took the port within 30 s of the last proof and before any other request found it out, and can play nothing to the server later. It also says what a signature doesn't do (no encryption; the answer's signature covers the status, not the body; clocks must agree within two minutes) and that a weak chosen token can be tried without limit from this computer now that loopback is exempt from the lockout. The security requirements, configuration (API_TOKEN), architecture and best-practices answers follow, and CHANGELOG says it in plain words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
… 20 ms Slice 54, D-01 made each request a little slower (it is signed, and its answer's signature checked), and the llmStatus test's fixed 20 ms sleep failed once under coverage. It now waits, up to 5 s, for the status the add refreshes, as docs/CODE_REVIEW.md asks of tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
The loopback exemption from the D-02 fix also exempted everyone behind a
reverse proxy on the same machine (Caddy or nginx in front of the server for
remote access): every remote client arrives from 127.0.0.1, so strangers on
the internet got unthrottled guesses at a weak chosen token, re-opening B-09
there.
A loopback request is now exempt only when it carries no forwarding header
(Forwarded, X-Forwarded-For, X-Real-IP, X-Forwarded-Host, CF-Connecting-IP,
True-Client-IP; any case, any value). One that carries one counts towards
the proof limit and the wrong-token lockout under {:proxied, peer}, shared by
all proxied requests: the header's claimed client address can be forged, so
it isn't used. The extension sends no forwarding header, so a local program
that adds one to its guesses locks out only the proxied requests, never the
extension (RateLimit.peer/1).
Regression tests that fail without this: 10 wrong tokens from 127.0.0.1 with
x-forwarded-for, then the 11th proxied request (with any of the six headers,
or the right token) gets 429 while the right token from 127.0.0.1 without
one gets 200; and three proofs through the proxy, then the fourth gets 429
while the extension's proofs keep getting 200. The HTTP API reference,
configuration, requirements and assurance case say exactly this, and the
residual risk now names the shared proxied lockout instead of unlimited
guesses through a proxy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
# Conflicts: # CHANGELOG.md
… task The engine swaps in 8 ms slices, so on a busy machine a test's last block could swap a slice after load() returned, and the assertion saw the old text: "a tabindex=0 chip…" failed CI this way on #36, a PR that only changes .gitleaks.toml. load() now waits until the page has had no change for 50 ms (at most 3 s). Under heavy load (128 parallel runs of the file), failing runs went from 30 to 8; the 8 left are one older test whose proper-noun check has a time limit of its own (recorded on SCR-764). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
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.
Fixes D-01 and D-02 from the fourth security reviewer on
v1.0.0-rc.2(slice 54, SCR-476). D-01 reopened medium finding B-01.D-01: the token never leaves the browser. On rc.2, the extension proved the server once per worker life and then sent
Bearer <token>. If the server stopped and another local account took the port, the next sync gave that account the token (the lead reproduced it: 3 requests carried the real token).Authorization: Kotiko-HMAC v1 ts=…, nonce=…, body=<sha256>, mac=…. The server accepts a signature only within 120 s of its clock, refuses a nonce it has already seen, and checks the body hash. Bearer still works for curl.X-Kotiko-Server). The extension uses an answer only when the signature checks out. Otherwise it clears its proof and sends nothing until a fresh proof succeeds. Any write (non-GET) needs a proof younger than 30 s.d-b01.mjsagainst a live server from this branch, killing the server and letting a squatter take both loopbacks:squatter log lines: 4; with the real token: 0. A fresh connect to the squatter is refused (server_key_rejected). The branch's e2e test also shows that a word added after the server dies never reaches the squatter.D-02: no local program or web page can lock the extension out.
application/json(415) or isn't a well-formed nonce, before counting it.Forwarded,X-Forwarded-For,X-Real-IP…) are counted together in one bucket, so internet clients behind a local proxy stay limited.Docs:
http-api.md(the scheme with test vectors), configuration, requirements, the assurance case (residual risks stated exactly), ARCHITECTURE, and the privacy policy, now version 4 (the access-key paragraph). Update the server and the extension together: an older server turns signed requests away, and the extension says to update it.Checks on the merged branch: lint;
npm test2060 pass; coverage 94.37 % lines, 85.02 % branches; perf; Playwright 114 passed (loopback, fullstack and security rerun by the lead: 15/15);mix test1187 tests and 23 properties, 0 failures, 91.65 %; credo strict; Sobelow; privacy and spec sync; REUSE; DCO.🤖 Generated with Claude Code