You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Zernio SDK leaks API keys between workspaces (module-global auth interceptor) #18
Found while verifying #16. Not caused by that work, and not WhatsApp specific.
@zernio/node registers its auth interceptor on a module-globalclient, so every new Zernio({ apiKey }) stacks another request interceptor on the same shared client. All of them run on every request, each overwriting the Authorization header, so the last-constructed key wins for all in-flight requests.
createZernioClient (lib/zernio-client.ts:12) is called per request, so on Vercel Fluid Compute, where one instance serves concurrent requests, workspace A's call to Zernio can carry workspace B's key and return workspace B's accounts, conversations and messages. Two workspaces syncing at the same time is enough.
Repro
importZerniofrom"@zernio/node";constseen=[];globalThis.fetch=async(req)=>{seen.push(req.headers.get("Authorization"));returnnewResponse(JSON.stringify({accounts: []}),{status: 200,headers: {"content-type": "application/json"},});};consta=newZernio({apiKey: "sk_AAAA"});constb=newZernio({apiKey: "sk_BBBB"});awaita.accounts.listAccounts();console.log("client A carried:",seen.at(-1));
client is module scope, and interceptors are never removed, so this is also an unbounded leak of interceptors over a process's lifetime.
Impact
Cross-tenant data exposure in any multi-workspace deployment, plus writes (sending a DM, registering a webhook) potentially executing against the wrong account. Severity depends on how often two workspaces are served concurrently by one instance.
Where to fix
The SDK, not here. It needs a per-instance client (or to pass the key per request) instead of mutating a shared one. There is no clean workaround in ZernFlow: setConfig, headers and interceptors are all on the same global.
Until it ships, ZernFlow is exposed on every route that builds a client per request: channels/sync, channels/test-key, channels/connect, messages, webhooks/late, the flow engine and both crons.
SDK (zernio-dev/zernio-node#7, published as @zernio/node@0.2.519): each Zernio instance now creates its own client via createClient(createConfig(...)), and every namespace method is routed through it by _bind, which reads _client at call time rather than bind time, because class fields initialize before the constructor body runs. baseURL and defaultHeaders moved onto that client too, since they were global for the same reason. _bind<F>(op: F): F preserves each operation's declared signature, so there are no consumer-visible type changes.
scripts/generate-client.ts emits this shape, verified byte-identical to the committed src/client.ts against the current live spec, so the next automated regeneration cannot silently revert it.
ZernFlow (#19): bumped to 0.2.519 with the lockfile, merged and deployed to production.
Verified. The repro from this issue, run against a clean install of the published package:
A carried: Bearer sk_AAAA
B carried: Bearer sk_BBBB
PASS: no key bleed
tests/isolation.test.ts pins it with 6 cases (per-instance keys, an earlier instance surviving a later construction, no interceptor stacking across 25 instances, per-instance baseURL and defaultHeaders, errors still surfacing). 4 of the 6 fail on the pre-fix client, so the test genuinely covers the defect. All 173 SDK tests and all 53 ZernFlow tests pass, and a live authenticated call against production zernflow.com succeeds on the new version.
Two notes:
Anyone else on @zernio/node ≤ 0.2.518 who constructs more than one client per process should upgrade. It is called out in the SDK CHANGELOG.
A quick look at the Python SDK found no equivalent module-global auth client. The other six language SDKs are unaudited for this pattern.
Activity
chore(deps): bump @zernio/node to 0.2.519 to close the key leak (#18) (…
mikipalet commentedon Aug 7, 2026
Fixed and shipped.
SDK (zernio-dev/zernio-node#7, published as
@zernio/node@0.2.519): eachZernioinstance now creates its own client viacreateClient(createConfig(...)), and every namespace method is routed through it by_bind, which reads_clientat call time rather than bind time, because class fields initialize before the constructor body runs.baseURLanddefaultHeadersmoved onto that client too, since they were global for the same reason._bind<F>(op: F): Fpreserves each operation's declared signature, so there are no consumer-visible type changes.scripts/generate-client.tsemits this shape, verified byte-identical to the committedsrc/client.tsagainst the current live spec, so the next automated regeneration cannot silently revert it.ZernFlow (#19): bumped to 0.2.519 with the lockfile, merged and deployed to production.
Verified. The repro from this issue, run against a clean install of the published package:
tests/isolation.test.tspins it with 6 cases (per-instance keys, an earlier instance surviving a later construction, no interceptor stacking across 25 instances, per-instance baseURL and defaultHeaders, errors still surfacing). 4 of the 6 fail on the pre-fix client, so the test genuinely covers the defect. All 173 SDK tests and all 53 ZernFlow tests pass, and a live authenticated call against production zernflow.com succeeds on the new version.Two notes:
@zernio/node≤ 0.2.518 who constructs more than one client per process should upgrade. It is called out in the SDK CHANGELOG.