Skip to content

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

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.

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_CLAIM without CYBERCHEF_AUTH_ISSUER is 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 403 with no insufficient_scope challenge, 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 entries
keyed by a stable caller: 1000 requests -> 995 denied, 1 map entry

Both 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 ms

No 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.

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.

Variable Default Meaning
CYBERCHEF_TENANT_CLAIM (unset) Token claim naming the tenant, e.g. tid. Unset = single-tenant. Requires CYBERCHEF_AUTH_ISSUER.

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 tenant field 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=true at 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. Review CYBERCHEF_RATE_LIMIT_REQUESTS before upgrading if you rely on it.
npm run lint clean
npm 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 requestId rate-limiter keying fails 2 of the 5 dispatch tests.