fix: generate the search token with a CSPRNG - #2217
Merged
Merged
Conversation
The search token is the shared secret behind every /search/* and /inference request: the client hashes it with argon2id and the server verifies that hash. Math.random().toString(36).substring(2) drew it from a non-cryptographic PRNG and produced a 7-15 character base-36 string, so the secret carried at most the 52 bits a double's mantissa can hold. - Generate the token from 32 bytes of node:crypto randomness, hex-encoded. - Cover the invariant with a test that fails on the previous implementation because Math.random is reached. - Update docs/security.md, which flagged the weak source.
felladrin
approved these changes
Jul 27, 2026
felladrin
left a comment
Owner
There was a problem hiding this comment.
Excellent! Thanks for another contribution!
6 tasks
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.
Description
Fixes #2181.
server/searchToken.tsmints the shared secret behind every/search/*and/inferencerequest — the client hashes it with argon2id asVITE_SEARCH_TOKEN, andverifyTokenAndRateLimitauthorizes by verifying that hash againstgetSearchToken(). It was generated withMath.random().toString(36).substring(2), so the secret came from a non-cryptographic PRNG and carried at most the 52 bits a double's mantissa can hold. The encoded string is also variable length — 7 to 15 characters over 2M samples on Node 22, not the ~11 it looks like.Now it's 32 bytes from
node:crypto, hex-encoded. The client already reaches for the CSPRNG (crypto.getRandomValuesinaccessKey.tsandsearchTokenHash.ts), so this brings the server side in line.docs/security.mdcalled out the weak source under "Security Best Practices", so that line is updated in the same commit.Scope note: this changes the quality of the secret, not its lifetime —
regenerateSearchToken()still only runs on build, andgetSearchToken()still returns the cached temp-file value.The other
Math.random()call sites (history.ts,logEntries.ts,querySuggestions.ts,wllama.ts,shared/openaiModels.ts, and the backoff jitter inserver/utils/streamUtils.ts) are all non-security and left alone.The new test pins the invariant rather than the implementation: it spies on
Math.random, asserts it is never reached, and checks two successive tokens differ. On the previous implementation it fails withexpected "random" to not be called at all, but actually been called 2 times.Type of Change
Checklist
npm run lintpassesnpm run test), with tests added where it made senseSecurity, performance, or breaking changes? Expand if relevant.
Security-positive, no migration needed. The token moves from ≤52 bits of
Math.random()output to 256 bits of CSPRNG output, and from a 7–15 character base-36 string to a fixed 64-character hex string. Existing deployments pick up the new token on their next build, exactly as they already do today — the temp file is rewritten byregenerateSearchToken()and re-inlined asVITE_SEARCH_TOKEN. argon2id takes the longer password unchanged (verified with a realhash-wasmround trip using the client's exact parameters).