v2.5.0 release notes
Release Date: 2026-09-02
Upstream Base: CyberChef v11.4.0 (unchanged)
Licence: GPL-3.0-or-later
Node: >=24 <27
Highlights
Section titled “Highlights”v2.5.0 completes the Enterprise Features milestone, the first of Phase 5’s three releases (v2.6.0 Distributed Architecture and v2.7.0 Observability follow). The roadmap line was “OAuth 2.1, RBAC, audit logging, multi-tenancy”; all four have now landed, and the first three shipped earlier in this release cycle.
The theme underneath them is the same in every case: this server was built for one caller. On stdio that is not an assumption, it is the truth — one client launches the process and owns its stdin. Every piece of shared state was correct under that assumption and wrong the moment the HTTP transport allowed a second caller. Multi-tenancy is the last of those pieces, and the rate limiter turned out to be a fourth thing that had never worked at all.
Multi-tenancy: what was actually shared
Section titled “Multi-tenancy: what was actually shared”Four pieces of process-wide state, each a different kind of problem:
| state | what a second caller could do | severity |
|---|---|---|
| Recipe store | list, read, modify and delete any other caller’s saved recipes | data exposure |
| Operation cache | learn whether someone else had already run a given input, by timing | side channel |
| Concurrency pool | consume every slot and deny service to everyone else | availability |
| Recipe cap | fill the store and stop every other tenant saving anything | availability |
The recipe store is the serious one. getStats leaked further than a count — tags and
categories are free text the user wrote, so an unscoped list handed one tenant a summary of what
every other tenant works on. And clear() replaced the whole store, so any caller could destroy
every tenant’s saved work in a single call.
The cache is subtler and worth stating precisely, because it is easy to overclaim. Operation results are deterministic, so a shared cache never handed one tenant another’s output — the same input yields the same answer either way. What it leaked was timing: a hit returns immediately and a miss does the work. Against a security toolkit, “has anyone here already decoded this sample” is a real question to be able to ask. It is the same shape as GHSA-rmg9-8936-vx66, which this project shipped a patch for one day earlier.
Where tenant identity comes from, and where it does not
Section titled “Where tenant identity comes from, and where it does not”From a claim on a token the server has already verified — signature, issuer, and RFC 8707
audience — named by CYBERCHEF_TENANT_CLAIM.
Never from a header, a query parameter, or an unverified token. Any of those would let a caller choose their own tenant, which is not isolation; it is the appearance of it.
Two consequences follow, and both are deliberate:
- Tenancy requires authorization. Configuring
CYBERCHEF_TENANT_CLAIMwithoutCYBERCHEF_AUTH_ISSUERis a startup error, not a silent downgrade. Without a verified token there is no identity to isolate on, so every caller would share one tenant while the operator believed they were separated — a failure that is invisible precisely when it matters. - A token that cannot be placed is refused, not defaulted. Dropping an unplaceable caller into
the default tenant would hand them whatever lives there. The refusal is a plain
403with noinsufficient_scopechallenge, because no amount of extra scope adds a claim the authorization server did not issue — challenging for scope would send the client round a token upgrade that cannot succeed.
A tenant identifier is validated against an allowlist before it is used as a storage key: path
separators, %, NUL, quotes, whitespace and control characters are all rejected, along with .,
.., and the reserved name default. Rejected rather than sanitised — silently rewriting
../../etc to etc maps two distinct tenants onto one key, which is the failure the module exists
to prevent.
The rate limiter has never limited anything
Section titled “The rate limiter has never limited anything”handleCallTool called rateLimiter.checkLimit(requestId). requestId comes from
logRequestStart, which returns a fresh randomUUID() for every request. So every call presented
as a caller never seen before, the sliding window was always empty, and nothing was ever refused —
since v1.7.0.
Measured before fixing, at a limit of 5 requests per 60 seconds:
keyed by requestId (as shipped): 1000 requests -> 0 denied, 1000 map entrieskeyed by a stable caller: 1000 requests -> 995 denied, 1 map entryBoth halves of that line are defects. The second is a memory leak: checkLimit pruned timestamps
within a caller’s array but nothing ever removed the caller, so the tracking Map gained an entry
per distinct key and kept it for the life of the process. Combined with the keying bug, that meant
one entry per request, forever, in a long-running HTTP server.
Why every existing test passed. lib-internals.test.mjs covers the sliding-window algorithm
thoroughly and keys the limiter on a stable connection id — as any reasonable unit test would. The
module was correct. The call site was not, and nothing tested the call site. This is the same
lesson as this release’s own F-02, where an authorisation bypass sat in the wiring while every unit
test passed.
The regression tests added here run against a real client and server over InMemoryTransport, and
were verified by mutation: restoring the requestId keying fails two of them. One of those tests
was first written as if (firstRefusal !== -1) expect(...) and passed against the unfixed
server, vacuously, because nothing was ever refused. A conditional assertion is not an assertion.
The worker-pool question, settled by measurement
Section titled “The worker-pool question, settled by measurement”Routing registry tools through the worker pool has been deferred since v2.4.0. Multi-tenancy appears to strengthen the case — concurrent tenants make main-thread CPU contention a fairness problem rather than merely a throughput one — so it was re-examined rather than inherited.
A cyberchef_rsa_attack Fermat search was run against a 2048-bit modulus with far-apart factors,
consuming its entire 10-second budget, while trivial calls were issued concurrently:
heavy tool ran for 10,004 ms (full budget, factored: false)trivial calls completed during it 10,218 median / p99 / worst latency 0 / 1 / 19 msNo starvation at all. The cooperative yielding introduced in v2.4.0 does the job; worker routing would buy multicore throughput and nothing else, and no measurement is asking for it. Deferred again, now on a direct measurement of the actual concern.
Getting there took three invalid attempts, which is the part worth recording: the first probe passed arguments the tool rejected, so it measured an error path; the second timed the heavy call after the probe loop rather than at resolution, so its “3,204 ms” was the loop’s own duration; the third chose factors close together, which is Fermat’s fast case, and finished in under 200 ms. Each looked like a clean result. This is v2.4.0’s F-17 exactly — a measurement that does not exercise the case it claims to is a measurement of something else — and it was caught only by asserting that the heavy call was still running at both ends of the sampling window.
Plans and release numbering, reconciled
Section titled “Plans and release numbering, reconciled”From v2.1.0 onward every release plan shipped one version later than its title, while ROADMAP.md
was kept current — so a plan headed “v2.4.0 — Enterprise Features” described what was actually
being built as v2.5.0.
Reconciled in two different ways on purpose:
- Unshipped plans renumbered to match ROADMAP: Distributed Architecture → v2.6.0, Observability → v2.7.0, Edge → v2.8.0, AI-Native → v2.9.0, Pre-v3.0.0 Polish → v2.9.x.
- Shipped plans annotated, not renamed. A plan for a release that already happened is a historical document; renaming it to match the version it turned into would tidy the directory at the cost of falsifying what was planned and when.
docs/planning/future-releases/README.md records the mapping. Nine broken relative links in the
planning tree were fixed alongside — the phase and strategy docs pointed at ./release-v2.x.md
from directories those files have never been in.
Environment variables
Section titled “Environment variables”| Variable | Default | Meaning |
|---|---|---|
CYBERCHEF_TENANT_CLAIM |
(unset) | Token claim naming the tenant, e.g. tid. Unset = single-tenant. Requires CYBERCHEF_AUTH_ISSUER. |
Upgrading
Section titled “Upgrading”Nothing changes unless you configure it. With CYBERCHEF_TENANT_CLAIM unset — every existing
deployment — the server runs in a single tenant and behaves exactly as before.
Two upgrade details worth knowing:
- Existing recipes keep working. A recipe written before v2.5.0 has no
tenantfield and belongs to the default tenant. Treating an absent field as “owned by nobody” would have left the file intact on disk and every list empty. - Rate limiting now actually applies. If you set
CYBERCHEF_RATE_LIMIT_ENABLED=trueat any point since v1.7.0, it has been doing nothing. It works now, so a limit that was never reached because it was never enforced may start refusing requests. ReviewCYBERCHEF_RATE_LIMIT_REQUESTSbefore upgrading if you rely on it.
Verification
Section titled “Verification”npm run lint cleannpm run test:mcp 1218 passed (42 files)Both new suites were verified by mutation rather than assumed:
- removing the tenant-scoping predicates from recipe storage fails 9 of the 29 tenancy tests;
- restoring the
requestIdrate-limiter keying fails 2 of the 5 dispatch tests.