Skip to content

Zernio SDK leaks API keys between workspaces (module-global auth interceptor) #18

Closed

Description

@mikipalet

Found while verifying #16. Not caused by that work, and not WhatsApp specific.

@zernio/node registers its auth interceptor on a module-global client, 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

import Zernio from "@zernio/node";

const seen = [];
globalThis.fetch = async (req) => {
  seen.push(req.headers.get("Authorization"));
  return new Response(JSON.stringify({ accounts: [] }), {
    status: 200, headers: { "content-type": "application/json" },
  });
};

const a = new Zernio({ apiKey: "sk_AAAA" });
const b = new Zernio({ apiKey: "sk_BBBB" });

await a.accounts.listAccounts();
console.log("client A carried:", seen.at(-1));
client A carried: Bearer sk_BBBB

Client A's request goes out with client B's key.

Relevant SDK code (node_modules/@zernio/node/dist/index.js, constructor):

client.setConfig({ baseUrl: this.baseURL, headers: { ... } });
client.interceptors.request.use((request) => {
  request.headers.set("Authorization", `Bearer ${this.apiKey}`);
  return request;
});

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.

Activity

  1. mikipalet commented on Aug 7, 2026

    @mikipalet
    MemberAuthor

    Fixed and shipped.

    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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions