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,dispositionlinkedid,uniqueid
What did NOT leak:
- Call recordings (the
/recordingendpoint does a per-rowCallRecord.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:
- Workflow engine's AMI Originate sets
accountcode = <orgId>before dialling (verified on GE's rows). - The per-leg PJSIP channel contains the org's
asterisk_peer_name, which embedscontext_prefix→ matched by thechannel 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_cdrfor 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
/callsreturns 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.