New:AI replies grounded in your own documentation.
All articles
Security9 June 202611 min read

Hardening a public chat widget: XSS, origin allowlists and prompt injection

A support widget sits on the open internet, accepts untrusted input, renders it in an authenticated dashboard, and forwards it to a model with access to your docs. Four surfaces, four fixes.

A chat widget is an unusually sharp piece of software to own. It runs on other people's websites, accepts input from anyone who can load a page, renders that input inside your authenticated dashboard, and passes it to a language model that has access to your documentation. Here's what we found when we went looking, and what we did about each surface.

1. The stored XSS we shipped

Our markdown renderer escaped the obvious characters — angle brackets and ampersands — and then interpolated user-controlled URLs into double-quoted HTML attributes. Quotes were not escaped. That's a break-out:

![x](https://example.com" onerror="alert(1))

A visitor sends that as a message. It executes on the host page in the widget, and — worse — in the agent's authenticated dashboard when they open the conversation. Stored, not reflected: it fires for every agent who looks at the thread.

The fix was small and the mistake in the first fix was instructive. Our instinct was to escape everything again inside the attribute, but the text has already been escaped upstream, so re-encoding ampersands double-escapes every URL query parameter and quietly breaks legitimate links. The attribute escaper needs to encode quotes and only quotes. We also tightened URL sanitisation to reject anything containing quotes, angle brackets, backticks or whitespace, and skipped bare-URL autolinking on those.

2. Snippet theft, and where to check for it

An embed snippet is public by nature. Anyone can copy it onto their own site and start conversations against your workspace — burning your AI budget and filling your inbox with traffic that isn't yours.

We added a domain allowlist, and the interesting decision was where to enforce it. Not on every request — on session creation, where the visitor token is issued. That's the choke point: no allowed origin means no token, which means no conversation, no messages and no model calls. One check, before anything costs money.

  • Exact hosts and wildcard subdomains both supported.
  • An empty list means allow everything, so the feature is opt-in and nobody's widget breaks on upgrade.
  • A missing Origin header — a non-browser client — is allowed through, because rate limiting and bans are the right tool for bots. This feature exists to stop snippet theft on real websites.

3. Volume

Rate limits are per IP and per visitor, with AI endpoints on a tighter budget than everything else, because those are the requests with a direct cost attached. Repeat offenders get progressively longer bans — fifteen minutes, an hour, six hours, a day — which handles the enthusiastic script without a human having to be awake. Websocket connections are capped per IP and event floods are cut off per socket.

Message content is capped and empty messages are rejected. Unglamorous, and it removes a whole family of cheap denial-of-wallet attacks.

4. Prompt injection through your own documentation

This is the surface that's easy to miss. If a customer can import a documentation site into the knowledge base, then the corpus contains text nobody on the team reviewed line by line. Somewhere in a few hundred crawled pages, sooner or later, is a sentence that reads like an instruction.

Retrieved passages are fenced in the prompt and labelled explicitly as untrusted reference data, with instructions never to follow commands found inside them, never to reveal the system prompt, and to stay in the support role. It isn't a proof, and anyone who tells you their injection defence is a proof is selling something — but it converts a trivially exploitable path into one that has to fight the system prompt for control.

What we didn't do

CORS is deliberately permissive, because the widget is designed to be embedded on domains we don't know about and auth is a bearer token rather than a cookie. CORS was never the boundary; the token issuance check is. We also skipped a CAPTCHA on session creation — rate limiting and progressive bans cover the same ground today without making every real customer prove they're human before asking a question.

Security work on a public widget is mostly deciding where the choke point is, then making sure the expensive things happen after it.

Talk to us

Questions about this post?A person reads every message.

Get answers