A support-chat widget where knowing someone's email is enough to read their private conversations — on every site that installs it
Cossistant is an open-source AI-and-human customer-support framework: an embeddable widget plus an agent dashboard. Its widget API identifies end-users the way Intercom does — you tell it who a visitor is — but unlike Intercom it never asks the customer's backend to prove it. The `identify` call accepts a client-supplied email with no signature, authenticated only by the public key that ships in every page's source. Because a conversation is readable by anyone who shares its contact, an attacker who knows a target's email can attach their own anonymous visitor to that contact and read the target's entire private support history — on any site running Cossistant. Confirmed end-to-end against a throwaway tenant. Disclosed privately on 2026-06-20 through the vendor's own security channel; published under the non-response policy after their stated 48-hour SLA lapsed with no acknowledgement.
Editor’s note: Everything reproduced below was verified against a throwaway tenant this research provisioned on the platform, populated only with planted fake data (
[email protected]and a self-authored “secret” message). No third-party end-user’s conversation, contact record, or PII was ever accessed. Because the issue is unpatched at the time of writing, the exact request sequence — the provisioning trick, the header set, and the runnable exploit — is withheld; this post describes the mechanism, not a recipe. The vendor holds the full unredacted report and a live proof of concept.
Target: cossistant.com — an open-source (github.com/cossistantcom/cossistant) AI + human customer-support framework for React/Next apps: an embeddable chat widget end-users talk to, and an agent dashboard support teams answer from. Backend API on api.cossistant.com; dashboard on app.cossistant.com. Tested surface: the public widget REST API. Engagement: 2026-06-20, against repository main.
Disclosure status: Reported privately to [email protected] on 2026-06-20 — PoC-first, with the root cause, the fix, and an offer to draft a private GitHub Security Advisory — through the channel named in the project’s own SECURITY.md, which advertises a 48-hour response SLA. No acknowledgement in the three weeks since. Published under the non-response policy. The vendor still holds the full unredacted report and a working proof of concept; if a fix ships, this post is updated with the patch reference and any coordinated advisory / CVE ID.
Executive summary
Every embeddable support widget faces the same problem: the code that runs on the customer’s page is public. The widget’s API key is right there in the page source, and anyone can read it. So the widget cannot be trusted, on its own, to say who the current visitor is — because “trust the client” means “trust the attacker.”
Intercom solved this years ago with identity verification. When you tell Intercom “this visitor is [email protected],” you also send an user_hash: an HMAC of that email, computed on your server with a secret key the browser never sees. Intercom recomputes it and rejects the claim if it doesn’t match. The public key can start a conversation; only a server-signed hash can claim to be a specific person.
Cossistant copied the shape of Intercom’s identify call — same fields, same intent — but not the signature. Its POST /identify accepts a client-supplied email (or externalId) with no HMAC, no signed hash, no server-side verification of any kind, authenticated only by the public key that ships in every page. It then does the one thing that verification exists to prevent: it links the caller’s anonymous visitor to whatever existing contact matches that email.
And conversation access follows the contact. The read check on a conversation is, in effect, “can this visitor prove they are the same contact who owns it?” — and the unverified identify call is exactly how an attacker makes that true.
Chain it together and the attack is three steps with no privileged access and no user interaction:
- Load any page that runs Cossistant; read the public key out of the source.
- Call
identifyclaiming the victim’s email. No signature is asked for, so none is refused. - Read the victim’s private support conversation history — messages and all.
The only thing the attacker needs to know is the target’s email address. The blast radius is every site that installs Cossistant, because the flaw is in the shared framework, not in any one deployment’s configuration.
What a scanner would have caught: none of it.
Every request in this chain is a well-formed, authenticated
200.identifyis supposed to accept an email — that’s its whole job; a scanner has no way to know that a signature should have been required alongside it. The conversation read is a legitimate authorization check doing exactly what it was written to do — it just trusts acontactIdthat the previous call let the attacker forge. There is no injection, no traversal, no error state to trip on. This is a missing security control, not a broken one, and missing controls are invisible to tools that only test whether requests succeed. Finding it means reading the auth model and asking “what proves this visitor is who they claim?” — and noticing the answer is “nothing.”
Architecture
| Component | Detail |
|---|---|
| Category | Open-source AI + human customer-support framework (embeddable widget + agent dashboard) |
| Backend | Bun + Turborepo monorepo; REST widget API on api.cossistant.com |
| Widget auth | Stripe-style public / secret API keys. The public key is embedded in the widget on every customer page; a domain/Origin whitelist is the only gate on public-key calls |
| Dashboard auth | better-auth (session-based), separate surface |
| Identity model | Anonymous visitors (client-supplied ID) are linked to persistent contacts by the identify call |
| Access model | A conversation is readable by any visitor who shares the owning contact |
The finding — cross-user conversation takeover via unverified identify
Severity: Critical — CVSS 3.1 ≈ 9.1 (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:L/A:N)
Class: Missing identity verification (Intercom user_hash absent) → cross-user data access
Three design decisions are individually reasonable and collectively fatal:
1. identify trusts a client-asserted identity with no signature. The request schema for the identify call carries visitorId, externalId, email, name, image, metadata — and no signature field exists at all. There is nowhere to put an user_hash even if a customer wanted to send one. The handler takes the asserted email/externalId and matches it against existing contacts, then links the caller’s visitor to whichever contact it found. Nothing checks that the caller is entitled to claim that identity.
2. The only auth on the call is the public key plus an Origin header. The identify endpoint accepts the public API key — which is, by design, visible in every customer’s page source. The single gate on public-key requests is an Origin whitelist. But Origin is just a request header: a browser sets it honestly, and a non-browser client (curl, a script, anything server-side) sets it to whatever string it likes. So the “gate” is a string the attacker controls. There is effectively no authentication barrier here that an attacker doesn’t already hold or can’t trivially forge.
3. Conversation read access is granted on a shared contact. The access check that guards conversation and message reads returns true, for a non-owner viewer, when the viewer’s contact equals the owner’s contact:
// conversation-access.ts — the non-owner branch, paraphrased
return Boolean(owner?.contactId && owner.contactId === viewer.contactId);
This is a sensible rule if viewer.contactId is trustworthy. Step 1 is precisely what makes it untrustworthy: the unverified identify call lets an attacker set their own viewer.contactId to the victim’s contact. The gate then opens exactly as designed — for the wrong person.
Why it matters. Support conversations are among the most sensitive data a product holds: account-recovery threads, billing disputes, private business details, whatever a distressed customer typed into a chat box believing it was private. This flaw exposes that history to anyone who knows an email address and can load a public widget — with no account, no phishing, no user interaction. And because conversations can also be opened as an identity you’ve claimed, the same primitive supports impersonation: open a thread as [email protected] to social-engineer the support team, or pollute a contact’s record with arbitrary name/email/metadata. The confidentiality break is the headline; the impersonation is the tail.
The reach is the multiplier. This is not one company’s misconfiguration — it’s the framework’s identify contract. Every site that embeds Cossistant inherits it.
Confirmed end-to-end (control included)
The finding was proven live against a self-provisioned throwaway tenant, with only planted fake data. The shape of the test, stated without the runnable steps:
- A victim visitor identifies as
[email protected]and opens a conversation containing a self-authored secret message. - Control — an un-identified attacker visitor requests that conversation →
404 Not Found. The base authorization is correct: a random visitor cannot read it. This matters — it rules out “everything is just public” and proves the gate is real. - Attack — the same attacker visitor calls
identifyclaiming[email protected](no signature), then requests the conversation again →200, secret message returned. The identify call is the only thing that changed between the404and the200.
That control-vs-attack pair is the whole proof: identical requests, one line of difference, denied then allowed.
The fix
Require server-side identity verification before linking a visitor to a pre-existing contact — the Intercom user_hash model:
- Add a signature field to
identify. The customer’s backend computes an HMAC of theemail/externalIdusing their secret key (never the public one) and sends it alongside. - On the server, recompute the HMAC and reject the identify if it doesn’t match. Only a verified claim may attach a visitor to an existing contact.
- Treat public-key-only
identifyas able to create a fresh anonymous contact, never to assume an existing one. The public key must not be sufficient to become a specific known user. - Stop relying on the
Originheader as an authentication boundary; it is a client-set string, useful for CORS convenience, worthless as a trust control on non-browser traffic.
The one-line version: the public key may start a conversation; only a server-signed hash may claim to be a person.
Properly defended (verified during the engagement)
Worth stating plainly, because it scopes the finding and it’s to the project’s credit:
- The base conversation-access check works — an un-identified visitor is correctly denied (
404). The system is not “read-everything-by-default”; the gate exists and holds. The entire problem is thatidentifyhands an attacker the key the gate checks for.
The auth model is close. It has the right access check and the right key split (public vs secret). What it’s missing is the single step that makes the key split mean anything on the identify path: proving, with the secret, that a claimed identity is real before honoring it.
Disclosure timeline
- 2026-06-20 — White-box review of the open-source widget API; cross-user takeover confirmed end-to-end against a self-provisioned throwaway tenant (fake data only). Reported privately, PoC-first, to
[email protected], through the channel named in the project’sSECURITY.md, with the impact, the root cause, the fix, and an offer of a private GitHub Security Advisory draft. - 2026-06-22 — The project’s advertised 48-hour response SLA elapsed with no acknowledgement.
- 2026-06-20 → 2026-07-10 — No response on the disclosure channel.
- 2026-07-10 — Published under the non-response policy. The vendor still holds the full unredacted report and a live proof of concept; this post is updated with the patch reference and any coordinated advisory / CVE ID if a fix ships.
Why this writeup exists
The bug is not a typo or a crash. It’s a missing sentence in the auth model: “and verify the caller is entitled to claim this identity.” Cossistant borrowed Intercom’s identify shape — the fields, the ergonomics, the developer experience — but left out the one part of it that isn’t developer-facing convenience and is instead the entire security premise. That’s the recurring failure mode in AI-accelerated codebases: the shape of a well-known pattern gets reproduced faithfully while the invisible, un-fun half of it — the signature, the check, the “prove it” — quietly doesn’t make the cut.
I do security research on AI-coded SaaS and the frameworks they build on. If you ship a widget, an SDK, or an API that other people’s users trust with private conversations, and you want a second pair of eyes on who your endpoints believe the caller is before someone else finds out, get in touch.