Skip to content

fix: harden the search token file permissions and hash digest - #2223

Merged
felladrin merged 3 commits into
mainfrom
fix/search-token-hardening-followups
Jul 27, 2026
Merged

fix: harden the search token file permissions and hash digest#2223
felladrin merged 3 commits into
mainfrom
fix/search-token-hardening-followups

Conversation

@felladrin

Copy link
Copy Markdown
Owner

Description

Follow-up to #2217, which made the search token a CSPRNG secret. Two things still capped what that secret buys, plus a test cleanup.

The token file was world-readable. regenerateSearchToken() wrote to the predictable path {os.tempdir()}/minisearch-token with default permissions (0666 & ~umask), so on a shared host any local user could read the token and mint valid /search/* and /inference requests, no matter how the bytes were generated. It's now created with 0600, plus a chmodSync right after, because mode only applies when the file is created and a token file left behind by an earlier build would otherwise keep its old permissions.

The argon2 digest was 8 bytes. The digest is the part a caller without the token would have to guess to forge a ?token=, so it sets the ceiling on forgery resistance: 8 bytes capped it at 64 bits, whatever the token's own entropy. hashLength in client/modules/searchTokenHash.ts is now 32. Cost is unchanged (hashLength doesn't affect argon2's work, only the output length), and the URL grows by about 32 characters.

Test cleanup: the older regenerateSearchToken should write a new token test is gone, since its assertions (length > 2, lowercase alphanumeric) are both implied by the /^[0-9a-f]{64}$/ check in the CSPRNG test from #2217.

One suggestion from the review of #2217 is deliberately not here: replacing that PR's afterEach(vi.restoreAllMocks) with a scoped mockRestore(). client/modules/notifications.test.ts already uses the afterEach form, so the file matches the existing convention, and the hook still runs when a test fails before reaching a manual restore. Leaving it alone.

What Where
Token file created 0600, plus chmodSync for files from earlier builds server/searchToken.ts
argon2 digest 8 → 32 bytes client/modules/searchTokenHash.ts
Test for the file mode; redundant token-shape test dropped server/searchToken.test.ts
First tests for the hash module (server-side round trip, digest length, cached-hash reuse) client/modules/searchTokenHash.test.ts
Permissions and digest length recorded docs/security.md

Type of Change

  • Bug fix
  • New feature
  • Documentation
  • Other (refactor, build, chore)

Checklist

  • npm run lint passes
  • Tests pass (npm run test), with tests added where it made sense

How to test

  1. npm run test (31 files, 282 tests). Both new tests fail on the pre-change code for the right reason: expected undefined to deeply equal { mode: 384 } and expected 8 to be 32.
  2. npm run build, then check the token file: ls -l "$(node -e "import('temp-dir').then(m => console.log(m.default))")/minisearch-token". It should be -rw------- and 64 bytes.
  3. chmod 0666 that file, run npm run build again, and it should be back to -rw-------.
Security, performance, or breaking changes? Expand if relevant.

Security-positive, no migration needed.

The local-read exposure only mattered on hosts where another user shares the temp directory; single-user machines and one-app containers were never affected. Deployments pick both changes up on their next build.

The wider digest needs no migration because every build mints a new token, so a cached lastSearchTokenHash from a previous build stops verifying anyway and gets regenerated at 32 bytes. Old 8-byte hashes would still be accepted if the token were unchanged (argon2 reads the parameters from the encoded hash), which is why this doesn't reject short digests explicitly.

One new failure mode worth naming: if the token file is owned by a different user, chmodSync now throws EPERM and the build fails, where before it would silently overwrite that user's file. That's the vulnerable case, so failing loudly is the intent, not a regression to work around.

Verified beyond the unit tests: a real npm run build on macOS produced a 64-byte 0600 file and repaired a file I had set to 0666 beforehand; and a client-produced hash with the new parameters was accepted by the real verifyTokenAndRateLimit() against the token that build wrote (garbage still gets a 401). No live SearXNG round trip, since the instance here returns 503.

The token is the CSRF/auth gate for the SearXNG proxy and /inference, but it
was written to a predictable temp path with default permissions, so any local
user could read it and mint valid requests. Creating it with 0600 closes that,
and the chmod covers a file left behind by an earlier build, since mode only
applies when the file is created.

Also drops the older regenerateSearchToken test: its assertions (length > 2,
lowercase alphanumeric) are both implied by the /^[0-9a-f]{64}$/ check in the
CSPRNG test.
The digest is the part a caller without the token would have to guess to forge
a ?token=, so an 8-byte one capped forgery resistance at 64 bits regardless of
how much entropy the token itself carries.

Adds the first tests for this module: a real argon2 round trip through the
server's own verification call, the digest length, and the cached-hash reuse
path. No migration needed, since every build mints a new token and cached
hashes stop verifying with it.
@felladrin
felladrin marked this pull request as ready for review July 27, 2026 13:12
@felladrin
felladrin merged commit cf23b83 into main Jul 27, 2026
6 of 7 checks passed
@felladrin
felladrin deleted the fix/search-token-hardening-followups branch July 27, 2026 13:12
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