Excel web add-ins are just web apps wearing an Office badge. That sounds obvious, but teams forget it all the time.
I’ve seen this play out the same way more than once: a team builds a task pane add-in, treats workbook data like “internal content,” renders it into the DOM, and accidentally creates a clean XSS path inside Excel. The UI looks harmless. The payload comes from a spreadsheet cell, a custom function result, or a document setting. Then somebody pastes attacker-controlled content into a workbook, shares it, and the add-in executes script in the task pane.
That’s the core mistake: trusting workbook data because it came from Excel.
It didn’t. It came from a user, a CSV import, a copied email, a third-party system, or another workbook. In security terms, it’s untrusted input.
The case: a finance team had an Excel add-in that reviewed rows and showed “record details” in a task pane. Users selected a row, and the add-in pulled values from the worksheet with Office.js, then rendered a summary panel with notes, account names, and status.
The code started simple and stayed simple for too long.
asyncfunction showSelectedRecord() {
await Excel.run(async (context) => {
const range = context.workbook.getSelectedRange();
range.load("values");
await context.sync();
const row = range.values[0];
const accountName = row[0];
const status = row[1];
const notes = row[2];
const html =`
<div class="card">
<h3>${accountName}</h3>
<p>Status: ${status}</p>
<div class="notes">${notes}</div>
</div>
`;
document.getElementById("record-details").innerHTML = html;
});
}
```text
If `notes` contains `<img src=x onerror=alert(document.domain)>`, the task pane runs it.
That’s textbook DOM XSS, but the real-world part is where the data came from. In this case, notes were imported from a partner CSV. Nobody thought of the spreadsheet as an attack delivery mechanism. They should have.
## How the bug was discovered
A customer reported that opening a workbook caused the add-in pane to “flash” and then redirect to a sign-in prompt that looked slightly off. The workbook had a notes field containing markup that injected a fake login form into the task pane. No browser exploit, no macro, no weird trick. Just unsafe HTML rendering.
The attacker’s cell value looked roughly like this:
```text
In a more aggressive version, they used an event handler payload:
This is boring code, which is exactly why I like it. textContent closes the whole class of bugs for plain text output.
Sometimes product wants formatting in notes: bold text, links, maybe lists. Fine. Then you need a real sanitizer, configured to allow only what you need.
CSP won’t fix unsafe DOM code, but it does make exploitation harder and catches mistakes earlier. For an Excel add-in, I’d set a strict policy and remove inline scripts completely.
Your exact frame-ancestors and allowed origins depend on your add-in hosting model and Office requirements. For implementation details, the CSP reference at https://csp-guide.com is useful, and Office add-in behavior should be verified against official Microsoft documentation.
The team also removed legacy inline event handlers and stopped using eval-adjacent patterns from old libraries. Good CSPs expose those bad habits fast.
This became a code review rule. Anything from range.values, worksheet metadata, or document storage is untrusted until encoded or sanitized for the target context.
If your environment supports it, Trusted Types are worth enabling because they force discipline around HTML injection sinks. Support can vary depending on the runtime around your add-in, so test it carefully before making it a hard dependency.
The visible result was boring: no more flashy injected markup, no fake prompts, no weird pane behavior when opening hostile workbooks.
The more meaningful result was architectural. The team stopped treating Excel as a trusted container and started treating the add-in as what it really was: a web app embedded in Office.
That mindset shift matters more than any single code patch.