Coda embeds look harmless right up until someone treats them like “just a bit of HTML from a trusted tool.”
That’s the trap.
I worked on a content-heavy app where editors could paste Coda doc links and get rich embedded content in articles. Nice feature. Fast to ship. Also a clean path to XSS once the implementation drifted from “embed a known provider” into “render whatever comes back.”
The bug wasn’t exotic. No browser zero-day, no weird parser edge case. Just a familiar chain of bad decisions:
- Accept user-controlled Coda URLs
- Fetch embed markup or metadata
- Drop it into the page with
innerHTML - Assume “Coda” means safe
That assumption is how people end up doing incident reviews on a Friday night.
The setup
The app had a CMS for internal teams and contractors. Editors could add a “Coda embed” block by pasting a URL like:
https://coda.io/d/_dAbCdEfGhIj/Some-Doc
The frontend sent that URL to a backend endpoint, which fetched details from Coda or an internal embed service. The response included HTML intended to render a preview card or iframe wrapper.
The original rendering code looked like this:
async function renderCodaEmbed(container, codaUrl) {
const res = await fetch(`/api/embeds/coda?url=${encodeURIComponent(codaUrl)}`);
const data = await res.json();
container.innerHTML = data.html;
}
If you’ve done appsec work for any length of time, your eye goes straight to innerHTML. Mine did too.
The team’s pushback was predictable:
- “But the HTML comes from our backend.”
- “The backend only supports Coda.”
- “Coda is a trusted provider.”
None of those statements fix DOM XSS.
How the bug actually happened
The backend endpoint tried to be flexible. That’s usually where these stories turn bad.
Here was the rough shape of the server code:
app.get('/api/embeds/coda', async (req, res) => {
const { url } = req.query;
if (!url.includes('coda.io')) {
return res.status(400).json({ error: 'Invalid provider' });
}
const embedRes = await fetch(`https://embed-service.internal/render?url=${encodeURIComponent(url)}`);
const embedData = await embedRes.json();
res.json({
html: `
<div class="embed embed-coda">
<h3>${embedData.title}</h3>
<div class="embed-body">${embedData.html}</div>
</div>
`
});
});
There were two problems.
1. Weak URL validation
This check is junk:
if (!url.includes('coda.io'))
An attacker can satisfy that with plenty of hostile URLs:
https://evil.example/?next=coda.io
https://coda.io.evil.example/
javascript:alert(1)//coda.io
If you allow arbitrary URL parsing games, someone will play them better than you.
2. Unsafe HTML composition
Even if the upstream provider is legitimate, embedData.title and embedData.html were treated as trusted HTML. If either field contained attacker-controlled markup, the page executed it.
A proof-of-concept used a crafted document title and a permissive embed service path to produce markup like this:
<div class="embed embed-coda">
<h3><img src=x onerror="fetch('/api/session').then(r=>r.text()).then(alert)"></h3>
<div class="embed-body">...</div>
</div>
The frontend then did exactly what it was told:
container.innerHTML = data.html;
Game over.
What made this real-world ugly
This wasn’t some public comment form where random users immediately got script execution. The feature lived in an authenticated editorial workflow. That made people underestimate it.
Bad idea.
Stored XSS in admin-ish surfaces is often worse than public reflected XSS because:
- higher-privilege users trigger it
- it sticks around in content
- it can pivot into account takeover, API abuse, or publishing malicious content at scale
In our case, the payload fired in the browser of editors and content admins reviewing drafts. That was enough to access sensitive internal APIs exposed to the SPA.
The “before” version
Here’s a condensed version of the vulnerable pattern:
// frontend
async function renderCodaEmbed(container, codaUrl) {
const res = await fetch(`/api/embeds/coda?url=${encodeURIComponent(codaUrl)}`);
const data = await res.json();
container.innerHTML = data.html;
}
// backend
app.get('/api/embeds/coda', async (req, res) => {
const { url } = req.query;
if (!url.includes('coda.io')) {
return res.status(400).json({ error: 'Invalid provider' });
}
const embed = await getEmbedData(url);
res.json({
html: `
<section class="coda-embed">
<h3>${embed.title}</h3>
${embed.html}
</section>
`
});
});
This combines three failure modes in one feature:
- bad origin validation
- server-side HTML interpolation without escaping
- client-side raw HTML injection
That’s a complete XSS kit.
The fix we shipped
We changed the design, not just the line of code.
The safest pattern for third-party embeds is boring:
- validate the URL strictly
- reduce data to structured fields
- render DOM nodes explicitly
- prefer sandboxed iframes over provider HTML blobs
Step 1: Strictly validate the Coda URL
Use the URL parser. Check protocol and hostname exactly.
function isAllowedCodaUrl(input) {
try {
const url = new URL(input);
const allowedHosts = new Set([
'coda.io',
'www.coda.io'
]);
return url.protocol === 'https:' && allowedHosts.has(url.hostname);
} catch {
return false;
}
}
Server-side:
app.get('/api/embeds/coda', async (req, res) => {
const { url } = req.query;
if (!isAllowedCodaUrl(url)) {
return res.status(400).json({ error: 'Invalid Coda URL' });
}
const embed = await getEmbedData(url);
res.json({
title: embed.title,
iframeSrc: embed.iframeSrc
});
});
No HTML comes back anymore. Just data.
Step 2: Render safely on the client
Build elements directly. Use textContent for text. Don’t hand HTML strings to the browser unless you absolutely must.
async function renderCodaEmbed(container, codaUrl) {
const res = await fetch(`/api/embeds/coda?url=${encodeURIComponent(codaUrl)}`);
const data = await res.json();
const wrapper = document.createElement('section');
wrapper.className = 'coda-embed';
const title = document.createElement('h3');
title.textContent = data.title || 'Coda document';
const iframe = document.createElement('iframe');
iframe.src = data.iframeSrc;
iframe.loading = 'lazy';
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin');
iframe.setAttribute('title', data.title || 'Coda embed');
wrapper.appendChild(title);
wrapper.appendChild(iframe);
container.replaceChildren(wrapper);
}
That one change kills a huge class of XSS issues.
Step 3: Sanitize only if you truly must allow HTML
Sometimes product requirements corner you into rendering provider HTML. If that happens, sanitize it with a well-maintained library like DOMPurify and still keep the allowed tags tiny.
Example:
import DOMPurify from 'dompurify';
function renderTrustedEmbedHtml(container, html) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['iframe', 'p', 'a', 'div', 'span'],
ALLOWED_ATTR: ['src', 'href', 'class', 'title', 'loading', 'sandbox', 'referrerpolicy'],
ALLOW_DATA_ATTR: false
});
container.innerHTML = clean;
}
I still don’t love this approach for embeds. It’s better than raw injection, but structured rendering is easier to reason about.
The “after” version
This is what the final flow looked like:
// backend
app.get('/api/embeds/coda', async (req, res) => {
const { url } = req.query;
if (!isAllowedCodaUrl(url)) {
return res.status(400).json({ error: 'Invalid Coda URL' });
}
const embed = await getEmbedData(url);
if (!isAllowedCodaUrl(embed.iframeSrc)) {
return res.status(502).json({ error: 'Invalid embed source' });
}
res.json({
title: String(embed.title || 'Coda document'),
iframeSrc: embed.iframeSrc
});
});
// frontend
async function renderCodaEmbed(container, codaUrl) {
const res = await fetch(`/api/embeds/coda?url=${encodeURIComponent(codaUrl)}`);
if (!res.ok) throw new Error('Failed to load embed');
const { title, iframeSrc } = await res.json();
const section = document.createElement('section');
section.className = 'coda-embed';
const heading = document.createElement('h3');
heading.textContent = title;
const iframe = document.createElement('iframe');
iframe.src = iframeSrc;
iframe.title = title;
iframe.loading = 'lazy';
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
iframe.sandbox = 'allow-scripts allow-same-origin';
section.append(heading, iframe);
container.replaceChildren(section);
}
Cleaner code. Less magic. Much harder to exploit.
The backup control that caught mistakes later
We also tightened CSP because I don’t trust a single layer to hold forever.
A good CSP would not have fixed the root bug, but it absolutely reduces blast radius when someone reintroduces unsafe rendering six months later.
At minimum, lock down script execution and frame sources. If you need help with policy design, CSP Guide is a solid reference.
We tested the deployed headers with HeaderTest because “I think the CDN preserved our CSP” is not validation.
A simplified policy looked like this:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-r4nd0m';
object-src 'none';
base-uri 'self';
frame-src 'self' https://coda.io https://www.coda.io;
frame-ancestors 'self';
Two practical notes:
- Don’t leave
unsafe-inlineinscript-srcand call it a day. - Don’t make
frame-src *just because embeds are annoying.
What I’d tell any team shipping Coda embeds
If your feature says “embed,” your threat model should immediately assume hostile markup will try to enter the page.
My checklist is simple:
- treat pasted URLs as untrusted input
- validate with
new URL(), not string matching - return structured data from the backend, not HTML
- render with DOM APIs and
textContent - use sandboxed iframes for third-party content
- add CSP so one coding mistake doesn’t become a breach
Most XSS bugs around embeds are self-inflicted. Not because the provider is malicious, but because teams blur the line between “trusted vendor” and “trusted HTML.”
Those are not the same thing. And if your code treats them as the same thing, you’ve already got a problem.