On October 14 Noma Labs published SneakPeek, an exploit chain in Claude Code Desktop's preview pane. A malicious HTML file in a public repository could run JavaScript when Claude previewed it, read files from your home directory, and send them to an attacker's server. Noma's list of what the proof of concept read includes SSH private keys, AWS keys, npm and GitHub tokens, Kubernetes and Docker credentials. Anthropic scored it CVSS 8.7 (High).
It's fixed: according to Noma's timeline, Anthropic shipped the fix on August 17, in Claude Desktop 1.30096.1 and later, through auto-update. If your app updates itself, you've had it for two months.
I build Reado, an IDE for reading the code your agent writes, so a bug that lives in "showing the user a file" is squarely my problem too. This post is about why this one is worth understanding even though it's closed, and what I checked in my own app after reading it.
What happened
When Claude reads or edits an HTML file, Claude Code Desktop shows it in a preview pane, so you see the result right away. Noma describes three defenses around that pane:
- a Content Security Policy with
script-src 'none', so no JavaScript runs; - loading the page as a
data:URL, which gives it an opaque origin, so it can't fetch local files; - a request filter on the dev-server proxy, which catches requests the renderer makes.
The gaps were in where each one applied. Files under 256KB were loaded as data: URLs, with the CSP. Files over 256KB were written to a temp file and loaded as file:// URLs, and that path never got the CSP. In Chromium a file:// page can fetch() other local files. And the request filter was only active when the pane was proxying a running dev server, not when it rendered a static file from disk.
So the attack is: pad an HTML file past 256KB, put a script in it, and wait for someone to look at it. Noma disguised it as a test coverage report, a file nobody is surprised to find large. The victim clones the repository and asks Claude to look at the coverage report. Claude reads the file, the pane renders it, the script runs.
There was no permission prompt at any point, and that's the part I keep thinking about. Claude didn't run a command or write a file. It read a file, which is the most harmless thing an agent can do. The code ran in the application's renderer, as a side effect of showing you the result.
The first fix, and the way around it
Noma reported the bug on July 4. Anthropic's first patch blocked fetch() and XMLHttpRequest calls to file:// from the page. Noma moved the same fetch() into a Web Worker created from a blob: URL, a context the filter didn't cover, and the files came out again. They reported the bypass on July 24, the same day the patch was ready for retest. On August 3 Anthropic confirmed the first fix was incomplete and started a complete one, covering, in Noma's words, "CSP, file:// loading, and the Worker bypass". It shipped on August 17.
Noma's write-up credits Anthropic for "iterating on every bypass until achieving a comprehensive, layered fix", and the timeline backs that up. But the bypass itself is the lesson. The first patch blocked the APIs the proof of concept had used. The attacker used another one. The complete fix went after the conditions instead: the CSP, the file:// loading, and the Worker.
Three things I take from it
A second code path is a second security model. My guess is that the 256KB cutoff was a performance decision: large data: URLs are slow. Nobody decides "large files get no CSP". But a threshold quietly sent the large ones down a path with different rules. If a feature has two ways of doing the same thing, each one needs its own security review. Better still, have one way.
The permission system guards the agent, not the app around it. Most of what we do to make coding agents safe is about the agent: approve this command, deny writes outside the project, sandbox the shell. All of that was in place here and none of it was involved. Carlos, commenting on a dev.to write-up of the bug, put it well: "Here a file in the repo was the attacker and the viewer was the tool." Anything that renders what's in a repository (previews, Markdown, notebooks, images, an embedded browser) is part of the attack surface, whether or not an agent is involved.
"Review this file" is when you trust the file least. The request that triggered SneakPeek was a review: look at this coverage report. That's the moment the file has been seen by nobody you trust, so the surface showing it should be the least capable one in the app, not the most.
How Reado shows a repository
Reado has the same problem to solve: it exists to show you the files in a repository, most of which you didn't write. After reading Noma's post I went through every place it does that, with SneakPeek's three questions: does the file's code run, can the page read the disk, can it send anything out.
-
An
.htmlfile opens as source code. Reado renders Markdown, Jupyter notebooks, images and PDFs; everything else, HTML included, is text in the editor. A coverage report is something you read, not something that runs. -
HTML inside Markdown and notebook outputs goes through a sanitizer. READMEs are full of HTML (centered logos, badges,
<details>), so Reado renders it, through rehype-sanitize, which strips scripts, event handlers andjavascript:URLs. -
Behind the sanitizer, the app's Content Security Policy. Only scripts that ship with Reado run (
script-src 'self'), and that rule covers workers too, so theblob:Worker trick has nothing to start from. No frames, and the page can't send requests (fetch(),sendBeacon()) anywhere but the app itself. - Size never changes how a file is loaded. A 500KB README goes through the same pipeline as a 5KB one. Past a size limit Reado stops instead: it asks before opening a large file, and doesn't open a huge one at all. It never switches to a different loader.
-
Local images come only from the open project. The protocol Reado uses for images on disk starts with an empty scope and is widened to the project folder when you open it, never to the rest of the disk. SVGs are shown through an
<img>tag, where SVG scripts don't run, and PDFs are drawn on a canvas.
The same pass turned up two bugs in a surface I hadn't thought of as "showing files": Reado's embedded browser, the pane for previewing your dev server. It takes URLs from two places a repository can control. Design comments, which live in .reado/ like every Reado comment, carry the URL of the page they're pinned to. And agents drive the pane through a command file in the same folder. So a cloned repository could ship a comment pointing at a file: or data: page, or a command already waiting for the pane to open. Both are fixed: the pane now opens only http and https pages, checked in one place in the Rust layer that every caller goes through, and a command that was already there when the pane opened is recorded, never run. The fixes are on main and ship in the next release.
The common thread is a choice I made for other reasons: Reado is built around reading code, so its default for a file is to show the text. That's less impressive than a live preview, and after SneakPeek it's one I'm glad of.
If you use these tools, or build them
If you use Claude Code Desktop, check that you're on Claude Desktop 1.30096.1 or later. Noma's post also lists the GitHub advisory and CVE as pending, so watch for those.
If you build a tool that shows files from a repository, a few rules follow from this bug:
- Treat every file in a cloned repository as input from a stranger, including the boring ones.
- One rendering path, with the same restrictions however big the file is.
- Apply your restrictions to where content comes from, not to the APIs it uses. Block
fetch()and an attacker reaches for a Worker. - Show the source by default. Render only what you have to.
Anthropic fixed this one, and kept at it until the bypass was closed too. The pattern isn't specific to them: every coding tool that shows you a file has a version of that preview pane. It's worth looking at yours before someone else does.
Top comments (0)