Skip to content

Incident: Apr 18 — Cross-Org Call Data Leak (SEV-1 Privacy)

Summary

A freshly approved tenant ("Zauto AI") saw another tenant's (GrandEstancia) AI-outbound call metadata on its dashboard within minutes of org creation. Root cause was an unscoped OR dcontext = 'ai-outbound' clause in the /calls WHERE predicate that bypassed org filtering. Fix shipped in ~20 minutes from discovery.

Timeline (IST)

Time Event
15:30 Completed self-serve → admin-approve flow work (PRs #46–#51)
15:40 Pre-verified admin@zautoai.com in Firebase Admin SDK (email delivery blocked by corporate MX)
16:15 User logged in as Zauto AI owner, created org via self-serve form
16:18 You (admin) approved the org — auto-provisioned SIP trunk + outbound route + ext 1001
16:19 Owner's dashboard Recent Calls panel showed GE's AI outbound calls — reported immediately with screenshot
16:20 Queried asterisk_cdr directly: confirmed leaked rows have accountcode = ba50c665-… (GE) but GET /calls matched them for Zauto anyway
16:23 Identified OR dcontext = 'ai-outbound' clause at api/src/server.js:4136 + :4326 as unscoped escape hatch
16:28 PR #54 — SECURITY: remove cross-org ai-outbound data leak merged to staging
16:29 PR #55 — promoted to main, prod auto-deploy ran
16:53 Prod API restarted with fix
16:55 Verified: Zauto GET /calls returns total: 0, GE GET /calls still returns its 457 calls unchanged

What was exposed

What leaked: AI-outbound CDR rows for ANY org, visible to any authenticated admin of any other org hitting /api/v1/calls or /api/v1/calls/history. Fields leaked:

  • src (originating CID — another org's DID)
  • dst (destination phone number dialled by another org's AI)
  • calldate, duration, billsec, disposition
  • linkedid, uniqueid

What did NOT leak:

  • Call recordings (the /recording endpoint does a per-row CallRecord.findOne({ where: { org_id } }) lookup, which was not bypassed).
  • Firestore call_logs (separate store, Firestore rules enforce org scoping).
  • Any data outside the platform — all viewers were authenticated platform admins.

Duration of exposure

The vulnerable clause dates back to when the /calls endpoint was first written with AI-outbound support. Prior to 2026-04-18 the prod org list was:

  • AstraPrivate (deleted 2026-04-17)
  • TechStart Inc (deleted 2026-04-17)
  • Acme Corporation (deleted 2026-04-17)
  • GrandEstancia

All four were under your admin control during the exposure window. No third-party tenant existed prior to Zauto AI. Effectively no cross-customer exposure occurred — the bug surfaced on the very first real external tenant creation.

Root cause

api/src/server.js:4136 (GET /calls) and :4326 (GET /calls/history):

WHERE (
  t.accountcode = ?             -- this org's id
  OR t.peeraccount = ?          -- this org's id
  OR t.channel LIKE ?           -- '%<this_org_context_prefix>%'
  OR t.dcontext = 'ai-outbound' -- MATCHES ALL ORGS' AI CALLS
) AND ...

The dcontext = 'ai-outbound' branch was added as a compatibility fallback for early AI-agent calls before accountcode was reliably set. It was never tightened once the workflow engine started setting accountcode on Originate.

Fix

PR #54 / #55 — dropped the clause entirely:

- "(t.accountcode = ? OR t.peeraccount = ? OR t.channel LIKE ? OR t.dcontext = 'ai-outbound')",
+ "(t.accountcode = ? OR t.peeraccount = ? OR t.channel LIKE ?)",

AI-outbound rows still match correctly because:

  1. Workflow engine's AMI Originate sets accountcode = <orgId> before dialling (verified on GE's rows).
  2. The per-leg PJSIP channel contains the org's asterisk_peer_name, which embeds context_prefix → matched by the channel LIKE '%<prefix>%' clause.

No DB migration. No dispatcher regeneration. Pure code change.

Verification

# Zauto (brand-new, no calls)
curl -H "X-Internal-Key: …" \
  "http://localhost:8000/api/v1/calls?org_id=728e57ec-0851-4fa7-903e-7a32e06cff95" \
  | jq '.pagination.total'
# → 0

# GE (still sees its own)
curl -H "X-Internal-Key: …" \
  "http://localhost:8000/api/v1/calls?org_id=ba50c665-7ab4-4f04-a301-eccc395dc42b" \
  | jq '.pagination.total'
# → 457

Preventative work

Shipped:

  • Troubleshooting Error 36 added with root cause and grep pattern for similar bugs.

Follow-up items (not yet done):

  • Audit every endpoint that queries asterisk_cdr for similar unscoped OR fallbacks. Candidates:
  • GET /api/v1/calls/:linkedId/journey — currently has no org scope at all.
  • GET /api/v1/calls/:callId/recording — scoped via per-row lookup, but worth re-verifying.
  • CDR poller for auto-tickets (cdr-sync.js).
  • Centralise the WHERE predicate into a helper orgScopedCdrWhere(orgId, contextPrefix) so it can't drift per-endpoint.
  • Add an integration test that stands up two orgs, places a call from each, and asserts each org's /calls returns only its own linkedids.

Lessons

  • Never widen org scope to a dcontext / route / endpoint type without a tenant discriminator. The original developer's intent was to include AI-outbound calls that didn't yet have accountcode set. The fix should have been to set accountcode on Originate, not to bypass the tenant filter.
  • Test new-org flows against live data from other orgs. We exercised the approve-org flow end-to-end earlier today but only against empty test orgs. The leak showed up the moment we pointed the flow at the real GE dataset.
  • Get the bug report mechanism right. The user spotted this within ~60 seconds of the new org loading the dashboard. A screenshot + "WTF why" got us from unknown to root-caused in under 5 minutes. Keep the feedback loop that tight.