Skip to content

security: fix open redirect and add small hardening#1227

Merged
rdimitrov merged 17 commits intomainfrom
jazzy-cell
Apr 29, 2026
Merged

security: fix open redirect and add small hardening#1227
rdimitrov merged 17 commits intomainfrom
jazzy-cell

Conversation

@rdimitrov
Copy link
Copy Markdown
Member

@rdimitrov rdimitrov commented Apr 29, 2026

What this does

Fixes an open redirect plus a handful of small, mostly-invisible hardening changes around it.

What's in it

  • The fix: trailing-slash redirects no longer let //evil.com/ redirect off-host.
  • SSRF block on the DNS/HTTP namespace verification — refuses to dial loopback, RFC1918, link-local, multicast, CGNAT, or IP-literal/single-label "domains".
  • Slow-client guard: ReadTimeout and IdleTimeout on the HTTP server (no WriteTimeout — would risk cutting off legitimate multi-package publishes).
  • Defence-in-depth headers on the UI (/) and 404 responses.
  • 5xx responses stop leaking raw DB error text to clients (logged server-side instead).
  • Search filter stops treating % and _ as wildcards.
  • Validators: PyPI/NuGet identifiers URL-escaped before fetching; MCPB no longer follows redirects.
  • Misc: Version field bounded to 255 chars; logs quote user input; BlockedNamespaces denylist now catches subdomain claimants.

Each change is its own commit so they can be reviewed (or reverted) independently.

Test plan

  • CI green
  • Synthetic publish in staging for each package type (npm, pypi, nuget, oci, mcpb) — confirms validator changes don't regress
  • Quick browser check of / after deploy to confirm UI still loads with the new CSP
  • curl https://<host>//evil.com/ — confirm the open redirect is closed

🤖 Generated with Claude Code

rdimitrov and others added 14 commits April 30, 2026 01:16
…g-slash middleware

A request like `//evil.com/` was parsed by Go's request-URI parser as a path
(leading `//` is not treated as authority when viaRequest=true), trimmed to
`//evil.com`, and emitted as `Location: //evil.com` — which browsers follow
off-host. Use `path.Clean` for the redirect target so any leading `//` is
collapsed to `/` (and the trailing slash is still removed). Also covers the
percent-encoded variant (`/%2Fevil.com/`), which the URL parser decodes to
`//evil.com/` before the middleware sees it.

Fixes GHSA-v8vw-gw5j-w7m6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /v0/auth/http endpoint fetched `https://<domain>/.well-known/mcp-registry-auth`
on a domain that only had to match a permissive regex. The regex accepted IP
literals (`127.0.0.1`, `169.254.169.254`, `10.0.0.5`) and single-label names
(`localhost`, `kubernetes`), and the underlying http.Client had no private-IP
guard. An unauthenticated attacker could probe internal HTTPS services from
inside the registry's network namespace; distinct error messages for
DNS-fail / non-200 / body-too-large gave a reachability oracle.

Two layers of defence:

  - IsValidDomain now rejects IP literals and single-label names. This is
    the cheap pre-flight filter and also covers the DNS verification path
    (where TXT lookups for internal zones gave a similar oracle).

  - DefaultHTTPKeyFetcher's transport now uses a custom DialContext that
    resolves the host once and refuses to dial loopback, RFC1918, ULA,
    link-local, link-local-multicast, or unspecified addresses. Dialling
    the resolved IP directly also pins against DNS rebinding (a TOCTOU
    where a domain validator sees a public IP and the actual dial sees an
    internal one). TLS SNI / Host headers continue to use the requested
    hostname since http.Transport sets them from the request URL.

The two `single part domain` test cases (using `localhost`) were exercising
the now-disallowed semantics and were removed; the equivalent pattern-derivation
logic is still covered by the `hyphenated domain` and `subdomain` cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ontext

The SSRF guard introduced in the previous commit returned the first dial
result for any non-blocked IP — success or failure. If DNS returned a
stale public AAAA record (or any individually-unreachable address)
before a working A record, auth failed where the default transport
would have recovered by trying the next answer.

Iterate through every non-blocked address; only report failure when
either every IP was blocked (no public address resolved) or every
public IP failed to dial.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Only ReadHeaderTimeout was set, leaving the registry vulnerable to
slow-read DoS (clients trickling the request body) and connection
hoarding via long-lived idle keep-alives.

  - ReadTimeout=30s bounds the full request read.
  - IdleTimeout=120s caps keep-alive connection holding.

WriteTimeout is intentionally NOT set: the publish path runs outbound
package validators sequentially (npm/pypi/nuget up to 10s each, OCI up
to 30s), and a tight cap could cut off a legitimate multi-package
publish mid-response — surfacing as a truncated read to the publisher
even when the DB commit succeeded. Slow-response-read DoS is bounded
upstream by NGINX ingress timeouts and the per-IP rate limit (180 rpm).
Revisit once validators are parallelised or per-request package counts
are bounded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Defence-in-depth for the registry UI at `/`. The page renders
publisher-controlled content (server names, descriptions, repository URLs);
server-side validation plus the JS escape function in ui_index.html are the
primary XSS defences, and these headers limit the blast radius if either is
bypassed.

  - X-Content-Type-Options: nosniff — prevent MIME sniffing
  - X-Frame-Options: DENY — block clickjacking framing
  - Content-Security-Policy — restrict script/style/connect/frame sources
    to self plus the documented Tailwind CDN

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
huma.Error5xx(detail, err) serializes the wrapped err into the response
body, which surfaces underlying pgx/SQL driver text (table/column hints,
internal hostnames in connection failures) to unauthenticated callers on
list/detail endpoints and to authenticated callers on edit/status flows.

Log the wrapped error server-side (with the relevant key — server name,
version) and return a sanitized message. 4xx responses that intentionally
relay user-actionable validator output are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The list endpoint wrapped filter.SubstringName as `%<input>%` and passed it
to ILIKE without escaping. A `_` in the input matched any single character
and `%` matched everything, letting a caller distort or enumerate the
server-name space (`?search=_` returns all 1-char names, `?search=%`
returns the whole table). Not SQL injection (still parameterized) and
results are paginated, so impact is filter shaping rather than data
exfiltration — but trivially fixable.

Replace `\` `%` `_` with their escaped forms and add ESCAPE '\' to the
ILIKE clause.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…mplifier

ServerJSON.Version and Package.Version had only minLength on their JSON
Schema annotations. The DB column is VARCHAR(255), so a 1MB version string
would still fail at the DB layer — but only after the request had paid for
JSON parse, validation, advisory-lock acquisition, and the registry-ownership
fetch (npm/pypi). At pool capacity, that path is a cheap DoS amplifier.

Add maxLength:"255" on both Go fields so huma rejects the request before
any of that work happens. The OpenAPI spec already declared this on
ServerJSON.Version; openapi.yaml is updated to match for Package.version,
and server.schema.json is regenerated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pypi.go and nuget.go interpolated pkg.Identifier and version into URL
templates via fmt.Sprintf without url.PathEscape. An identifier such as
\"foo/../bar\" normalised under url.URL parsing to a fetch against an
unrelated package's metadata; an identifier with embedded \"?\" or \"#\"
truncated the path. The validator could end up checking the wrong package
for the publisher's mcpName claim. Validator inputs already pass the
publish-time identifier regex, but defence-in-depth at the URL boundary
costs nothing.

Applied to PyPI's /pypi/<id>/<ver>/json fetch, NuGet's package-base
index.json fetch, and the NuGet README URL template substitution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The MCPB validator HEADs the publisher-supplied package URL to confirm
public accessibility. Default http.Client follows up to 10 redirects;
the URL allowlist (github.com / gitlab.com) only constrains the FIRST
hop. A 30x bouncing through CDN/release-asset hosts could otherwise be
steered toward attacker infrastructure or internal-only endpoints
reached via DNS quirks.

Set CheckRedirect to ErrUseLastResponse and treat the resulting 3xx as
\"accessible\" when a Location header is present (legitimate path: GitHub
release assets serve a 302 to a signed S3 URL; we don't need to follow
to confirm reachability).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
log.Printf calls in the v0 handlers include url.PathUnescape'd server
names and versions via %s. A path parameter containing %0A (newline) or
terminal escape codes would split the log line, letting an attacker
forge synthetic log entries seen by SIEM/log readers. Switch to %q so
user input is always quoted unambiguously.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two gaps reported by the deep-review pass:

  - General multicast (admin-scoped 239/8 and ff00::/8) was not blocked.
    IsLinkLocalMulticast covered only 224.0.0/24 and ff02::/16. Replace
    that narrower check with the broader IsMulticast.

  - Carrier-Grade NAT (RFC 6598, 100.64.0.0/10) is not classified by any
    Go stdlib Is* helper but is reachable on some cloud / mobile networks
    where it shadows internal infrastructure. Add an explicit CIDR check.

Tests cover the new positive and negative boundaries (100.63.x and
100.128.x are still allowed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
handle404 returned application/problem+json without nosniff. Sibling to
the / handler set in b5dc495 — defence-in-depth in case the body is
ever rendered in a context that does MIME sniffing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GenerateTokenResponse rejected JWT issuance when the candidate token
held publish permission for \"<blocked>/test\". DNS-issued tokens for a
subdomain claimant carry both \"com.example/*\" (slash) and
\"com.example.*\" (dot-wildcard) patterns; an attacker who proves DNS
ownership of mailer.evil.com but not evil.com receives only the
dot-wildcard form (\"com.evil.mailer.*\"), which strings.HasPrefix
against \"com.evil/test\" fails to match — the block silently fails
open exactly where it is intended to apply.

Probe both \"<blocked>/test\" and \"<blocked>.test/x\" so subdomain
issuances are covered. BlockedNamespaces is empty in prod today but
this closes the gap before it gets populated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rdimitrov rdimitrov changed the title security: fix open redirect (GHSA-v8vw-gw5j-w7m6) and harden related surface security: fix open redirect and add small hardening Apr 29, 2026
rdimitrov and others added 3 commits April 30, 2026 02:08
…working

The CSP added in the previous commit set connect-src 'self', which silently
blocked the page's prod / staging / custom base-URL selector — the whole
point of which is to issue cross-origin XHRs from a single static page.

Switch to connect-src *. The other directives (script-src, style-src,
frame-ancestors, base-uri, form-action, default-src) still meaningfully
limit the page's attack surface; connect-src isn't useful as a control
when the feature it would constrain is by design cross-origin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fall-through loop tried each non-blocked address in DNS-return order
under a single shared 10s http.Client budget. A hanging first address
(stale AAAA, broken route, blackholed IP) could consume most of the
budget before we ever tried the next answer, surfacing as a generic
auth failure where Go's default Happy Eyeballs would have raced the
addresses and recovered.

Wrap each per-IP DialContext in a 3s context.WithTimeout. We give up
fast on a hung address and try the next one, keeping the overall budget
useful for the address that actually answers. Simpler than implementing
true Happy Eyeballs and sufficient for the well-known fetcher's use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…or schema

The Go struct tag and openapi.yaml were updated to maxLength: 255 in an
earlier commit, but the JSON schema embedded for use by /v0/validate was
missed. This left the CLI / schema-driven validation paths without the
bound that the API layer enforces, so a 1MB version string would slip
past /validate even though it would be rejected at publish time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rdimitrov rdimitrov merged commit 1201cbd into main Apr 29, 2026
5 checks passed
@rdimitrov rdimitrov deleted the jazzy-cell branch April 29, 2026 23:14
@rdimitrov rdimitrov mentioned this pull request Apr 29, 2026
3 tasks
rdimitrov added a commit that referenced this pull request Apr 29, 2026
## Summary

- Bumps prod imageTag from `1.7.4` to `1.7.5`.
- Promotes the security release tagged at v1.7.5 (#1227 merged at
1201cbd).

## Test plan

- [ ] Smoke staging at https://staging.registry.modelcontextprotocol.io
— already done, all checks green
- [ ] Roll out via Pulumi
- [ ] Confirm prod health post-rollout: `curl
https://registry.modelcontextprotocol.io/v0/health`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant