The public catalogue UI served at GET / (file internal/api/handlers/v0/ui_index.html) is vulnerable to stored cross-site scripting via the server.websiteUrl field of any published server.json. Server-side validation in internal/validators/validators.go (validateWebsiteURL) only checks that the URL parses, is absolute, and uses the https scheme; it does not reject quote characters. Client-side, the value is interpolated into a double-quoted href attribute via innerHTML, using a homegrown escapeHtml helper that performs the standard textContent → innerHTML round-trip. Per the HTML serialisation algorithm, that round-trip encodes only &, <, > and U+00A0 inside text nodes — it does not encode " or '. A literal " in websiteUrl therefore breaks out of the href attribute, allowing arbitrary on* event handlers to be appended to the same <a> element. The Content-Security-Policy on / is script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com, so the injected event handlers execute.
Any user able to obtain a publish token (e.g. via POST /v0/auth/github-at with their own GitHub account, or POST /v0/auth/none on a deployment that has anonymous auth enabled) can plant a poisoned record visible to every visitor of the registry homepage.
internal/validators/validators.go — validateWebsiteURL (lines 153–199)internal/api/handlers/v0/ui_index.html — toggleDetails(card, item) at line 432, the href attribute built around escapeHtml(server.websiteUrl)escapeHtml defined at internal/api/handlers/v0/ui_index.html lines 494–498Obtain a Registry JWT for any namespace you control (a GitHub OAuth exchange against a throwaway account suffices):
TOKEN=$(curl -sS -X POST https://registry.modelcontextprotocol.io/v0/auth/github-at \
-H 'Content-Type: application/json' \
-d '{"github_token":"<gh-pat>"}' | jq -r .registry_token)
Publish a server with a poisoned websiteUrl. The literal " is preserved end-to-end:
curl -sS -X POST https://registry.modelcontextprotocol.io/v0/publish \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
--data-binary @- <<'EOF'
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
"name": "io.github.<your-account>/xss-poc",
"version": "0.0.1",
"description": "hover the website link",
"websiteUrl": "https://example.com/\"onmouseover=alert(document.domain)//"
}
EOF
Visit https://registry.modelcontextprotocol.io/, search for xss-poc, click the card to expand it, then hover the Website link in the details panel. The injected onmouseover fires and alert(document.domain) runs on the registry.modelcontextprotocol.io origin.
Go's net/url.Parse accepts literal " in the path component:
input="https://example.com/\"onmouseover=alert(1)//" IsAbs=true Scheme="https" Path="/\"onmouseover=alert(1)//"
Neither the Huma format:"uri" annotation nor validateWebsiteURL's scheme/IsAbs triplet rejects this string. The architecture's existing protection — repository.url is regex-locked to ^https?://(www\.)?github\.com/[\w.-]+/[\w.-]+/?$ and therefore cannot contain quotes — does not extend to websiteUrl, which has no allowlist.
escapeHtml does not catch thisfunction escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
Per the HTML5 spec (§13.3 Serialising HTML fragments), the only characters encoded inside the text content of an element are &, <, >, and U+00A0. " and ' are not encoded because in a text-content context they are not special. The helper is therefore safe in element-text contexts (where it is correctly used for name, version, description, etc.) but unsafe inside an attribute value, which is precisely where it is invoked for href on lines 432 and 426.
registry.modelcontextprotocol.io origin, the injected script can:
localStorage (baseUrl, customUrl), pinning the user's subsequent reads to an attacker-controlled "Custom" base URL.connect-src * is granted).script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com does not block this because 'unsafe-inline' permits inline event-handler attributes.escapeHtml with an attribute-safe encoder that also escapes ", ', backtick, and = — the OWASP HTML attribute-encoding rule.href via string templates. Use setAttribute('href', value) instead — setAttribute is not subject to HTML tokenisation, so no breakout is possible.validateWebsiteURL to reject any URL whose raw bytes contain ", ', <, >, , \t, or \n, or — conservatively — store the canonical re-serialised form (parsedURL.String() percent-encodes such characters in the path).'unsafe-inline' from script-src after auditing the inline scripts on the page.Approach (3) is the smallest server-side change and immediately neutralises the exploit for any new publishes; approaches (1) or (2) close the class of bug at the sink so future fields with similar patterns are safe by default.
{
"cwe_ids": [
"CWE-79",
"CWE-116"
],
"nvd_published_at": null,
"severity": "MODERATE",
"github_reviewed_at": "2026-05-08T17:18:32Z",
"github_reviewed": true
}