Skip to content

fix(registry): block SSRF redirects and cap response bodies (#21)#47

Merged
m1ngshum merged 1 commit into
mainfrom
fix/issue-21-registry-ssrf
Jun 2, 2026
Merged

fix(registry): block SSRF redirects and cap response bodies (#21)#47
m1ngshum merged 1 commit into
mainfrom
fix/issue-21-registry-ssrf

Conversation

@m1ngshum

@m1ngshum m1ngshum commented Jun 2, 2026

Copy link
Copy Markdown
Member

What

Hardens the shared RegistryClient HTTP path (src/registry/client.ts) against SSRF and decompression-bomb DoS, addressing the two issues in #21.

  1. No silent redirect following (SSRF). fetch now runs with redirect:"manual". Any opaqueredirect (status 0) or explicit 3xx is rejected as a RegistryError instead of being followed to an attacker-chosen/internal host (169.254.169.254, localhost, ...).
  2. Bounded body (DoS). Replaced unguarded response.json() with a capped read: an over-cap Content-Length is rejected before reading; streamed bodies are read chunk-by-chunk and aborted once MAX_RESPONSE_BYTES (10 MB) is exceeded — before fully decompressing a bomb. Non-stream/injected responses fall back to json() under the Content-Length guard.
  3. baseUrl validation. The caller-overridable baseUrl is now validated in the constructor (https-only, no embedded credentials, reject loopback/private/IPv4-mapped-IPv6 hosts) by reusing isPrivateHost from publish-client.ts (now exported). Mirrors validateRegistryUrl from [security][HIGH] mcpm publish sends GitHub token to arbitrary --registry host (token exfiltration) #17.

Why

baseUrl is fully overridable and responses were host-validated only by Zod (shape), not by origin. A registry response or malicious baseUrl could redirect the client to internal addresses, and await response.json() read unbounded bodies with transparent gzip/br decompression — a small compressed payload could expand to GBs (OOM).

Tests

Added to src/registry/registry.test.ts (all fail on pre-fix code):

  • redirect (opaqueredirect / 302) is rejected, and redirect:"manual" is passed to fetch
  • oversized Content-Length rejected before body read
  • oversized streamed body (no Content-Length) rejected by the running cap
  • small streamed body still parses correctly
  • baseUrl validation: http, loopback, 169.254.169.254, 10.x, IPv4-mapped IPv6 loopback, embedded creds, malformed — all rejected; public https and default accepted

pnpm lint && pnpm test && pnpm build all pass (1128 tests, +15).

Scope is limited to src/registry/ so this PR stays independently mergeable.

Closes #21

🤖 Generated with Claude Code

The shared RegistryClient HTTP path had two SSRF/DoS gaps:

1. Redirect following — fetch ran without redirect:"manual", so a registry
   (or attacker-supplied baseUrl) could 30x-redirect us to internal hosts
   (169.254.169.254, localhost). Now we set redirect:"manual" and treat any
   opaqueredirect / 3xx as a RegistryError instead of following it.
2. Unbounded body — await response.json() read the whole body with transparent
   gzip/br decompression and no cap (decompression-bomb → OOM). Now we reject on
   an over-cap Content-Length and read streamed bodies chunk-by-chunk, aborting
   once MAX_RESPONSE_BYTES (10 MB) is exceeded.

Also validate the caller-overridable baseUrl up front (https-only, no embedded
creds, reject loopback/private/IPv4-mapped-IPv6 hosts) by reusing isPrivateHost
from publish-client (now exported). Mirrors validateRegistryUrl for #17.

Adds tests covering redirect rejection, oversized Content-Length, oversized
streamed body, and baseUrl validation.

Closes #21
@m1ngshum m1ngshum merged commit f7d4245 into main Jun 2, 2026
7 checks passed
@m1ngshum m1ngshum deleted the fix/issue-21-registry-ssrf branch June 2, 2026 08:17
@m1ngshum m1ngshum mentioned this pull request Jun 2, 2026
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.

[security][MEDIUM] Registry client follows redirects (SSRF) and reads unbounded response bodies

1 participant