Advisory Details
Title: Mercury fetch_url SSRF allows authenticated users to access special-use IPv4 resources
Description:
Summary
An authenticated Mercury web user can induce the host to send arbitrary HTTP requests to special-use IPv4 destinations through the built-in fetch_url capability and reflect the response body back into assistant output. Because fetch_url forwards a model-controlled URL directly into fetch() with no SSRF policy, internal HTTP services reachable from the Mercury host become readable through normal chat usage.
Details
The vulnerable sink is src/capabilities/web/fetch-url.ts. createFetchUrlTool() accepts an arbitrary url argument and immediately performs a server-side request with Node's fetch():
export function createFetchUrlTool() {
return tool({
inputSchema: zodSchema(z.object({
url: z.string().describe('The URL to fetch'),
format: z.enum(['text', 'markdown']).optional(),
})),
execute: async ({ url, format }) => {
const resp = await fetch(url, {
signal: controller.signal,
headers: {
'User-Agent': 'Mercury-Agent/0.1.0',
'Accept': 'text/html,application/json,text/plain',
},
});
There is no destination validation before the request is issued: no rejection of loopback, RFC1918, link-local, metadata, or other non-global/special-use addresses; no allowlist; no explicit dangerous opt-in; and no redirect target validation.
The issue is reachable from Mercury's normal authenticated web interface. POST /api/chat/send accepts user-controlled chat content and forwards it into the web channel, and CapabilityRegistry registers fetch_url as an exposed tool in the agent tool loop. In practice this means a web-authenticated attacker only needs to supply a prompt that causes the model to invoke fetch_url on a sensitive destination.
I verified this with Mercury's real built web service and real /api/chat/send path. For deterministic tool selection, the test harness used a local OpenAI-compatible stub provider, but Mercury source code, routing, tool registration, and the vulnerable network sink were not mocked or patched. During the verification run, Mercury fetched http://198.18.0.1:44275/secret, the canary listener recorded one inbound request with Host: 198.18.0.1:44275, and the secret-bearing response was reflected back to assistant output. The control run changed only the target URL to https://example.com and produced zero local canary hits.
For versioning, I verified that the vulnerable code is present in the upstream v1.2.0 tag (035b8dbfba365749491c0a3d827b828d1db25de9). The latest published GitHub release object is v1.1.13 (published June 18, 2026), and it is also affected, but the canonical repository also contains the higher verified upstream tag v1.2.0 (commit dated April 27, 2026). I am therefore reporting the affected range as <= 1.2.0, with occurrences pinned to that remote-valid v1.2.0 commit.
PoC
Prerequisites
- A checkout of
cosmicstack-labs/mercury-agent with dependencies installed and built dist/index.js present.
- Node.js 20+ and Python 3.
- An environment where Mercury's web dashboard can bind to localhost.
- The three PoC scripts below saved in the same working directory.
- No Mercury source patching is required. The harness creates an isolated
MERCURY_HOME, logs in through the real web dashboard API, starts a local canary HTTP service, and uses a local OpenAI-compatible stub only to deterministically emit the fetch_url tool call.
Reproduction Steps
- Download the shared harness from: common_harness.py
- Download the verification script from: verification_test.py
- Download the control script from: control-public-url.py
- From the PoC working directory inside the Mercury repository, run:
python3 verification_test.py
- Observe that the harness starts Mercury's real web service, authenticates through
/api/auth/login, sends a chat message through /api/chat/send, and drives fetch_url to http://198.18.0.1:<canary-port>/secret
- Confirm the verification result reports one canary hit and assistant output showing the SSRF succeeded
- Run the control path:
python3 control-public-url.py
- Confirm the control result reports zero canary hits while
fetch_url still succeeds for the public URL
Log of Evidence
Verification Mode: Integration-Test
Standard Interface: HTTP API (/api/chat/send)
Target URL: http://198.18.0.1:44275/secret
Assistant Text: [DEFECT REPRODUCED] fetch_url reached the special-use IPv4 canary through Mercury's real web chat interface.
Canary Hit Count: 1
Result: [DEFECT-CONFIRMED-WITH-LIMITATIONS] Special-use IPv4 access worked through Mercury fetch_url.
verification_observation.json excerpt:
- canary_hit_count: 1
- canary_hits[0].path: /secret
- canary_hits[0].headers.host: 198.18.0.1:44275
- canary_hits[0].headers.user-agent: Mercury-Agent/0.1.0
Verification canary response reflected by Mercury:
- {"secret":"special-use-canary-cve-2026-32019","path":"/secret"}
Control Mode: Integration-Test
Standard Interface: HTTP API (/api/chat/send)
Target URL: https://example.com
Assistant Text: [CONTROL BASELINE] fetch_url returned the expected public Example Domain content.
Canary Hit Count: 0
Result: [CONTROL-PASSED] Benign public fetch succeeded without special-use IPv4 access.
Impact
This is an authenticated server-side request forgery issue that leads to internal information disclosure. Any Mercury user who can influence the agent prompt and tool selection can cause the Mercury host to send HTTP requests to special-use or otherwise non-global IPv4 targets that are reachable from the host. If those internal services return secrets, metadata, configuration, or other sensitive content, Mercury will pass that data back into assistant output. Depending on host placement, this can expose local admin panels, service discovery endpoints, cloud metadata services, or application-internal APIs.
Affected products
- Ecosystem: npm
- Package name: @cosmicstack/mercury-agent
- Affected versions: <= 1.2.0
- Patched versions:
Severity
- Severity: Medium
- Vector string: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N
Weaknesses
- CWE: CWE-918: Server-Side Request Forgery (SSRF)
Occurrences
| Permalink |
Description |
|
chat.post('/api/chat/send', async (c) => { |
|
if (!webChannel) { |
|
return c.json({ error: 'Web channel not initialized' }, 503); |
|
} |
|
|
|
const body = await c.req.json<{ content: string; threadId?: string }>(); |
|
if (!body.content?.trim()) { |
|
return c.json({ error: 'Message content required' }, 400); |
|
} |
|
|
|
try { |
|
const threadId = (body.threadId && body.threadId.trim()) ? body.threadId.trim() : 'web:default'; |
|
webChannel.emitMessageInThread(body.content.trim(), threadId); |
|
return c.json({ sent: true }); |
|
Authenticated /api/chat/send accepts user-controlled message content and forwards it into the Mercury web channel for agent processing. |
|
this.tools.github_api = createGithubApiTool(); |
|
logger.info('GitHub tools registered'); |
|
} |
|
|
|
this.tools.fetch_url = createFetchUrlTool(); |
|
logger.info('Web fetch tool registered'); |
|
CapabilityRegistry registers fetch_url, making the network fetch primitive available inside the agent's tool loop. |
|
export function createFetchUrlTool() { |
|
return tool({ |
|
description: 'Fetch a URL and return its content as text. Strips HTML to readable markdown-like format. Useful for reading documentation, APIs, or web pages.', |
|
parameters: z.object({ |
|
url: z.string().describe('The URL to fetch'), |
|
format: z.enum(['text', 'markdown']).optional().describe('Output format (default: markdown)'), |
|
}), |
|
execute: async ({ url, format }) => { |
|
const outputFormat = format ?? 'markdown'; |
|
|
|
try { |
|
const controller = new AbortController(); |
|
const timeout = setTimeout(() => controller.abort(), 15000); |
|
|
|
const resp = await fetch(url, { |
|
signal: controller.signal, |
|
headers: { |
|
'User-Agent': 'Mercury-Agent/0.1.0', |
|
'Accept': 'text/html,application/json,text/plain', |
|
}, |
|
}); |
|
createFetchUrlTool() accepts an arbitrary url and passes it directly to fetch() with no SSRF screening for special-use, loopback, private, or otherwise non-global destinations. |
Advisory Details
Title: Mercury
fetch_urlSSRF allows authenticated users to access special-use IPv4 resourcesDescription:
Summary
An authenticated Mercury web user can induce the host to send arbitrary HTTP requests to special-use IPv4 destinations through the built-in
fetch_urlcapability and reflect the response body back into assistant output. Becausefetch_urlforwards a model-controlled URL directly intofetch()with no SSRF policy, internal HTTP services reachable from the Mercury host become readable through normal chat usage.Details
The vulnerable sink is
src/capabilities/web/fetch-url.ts.createFetchUrlTool()accepts an arbitraryurlargument and immediately performs a server-side request with Node'sfetch():There is no destination validation before the request is issued: no rejection of loopback, RFC1918, link-local, metadata, or other non-global/special-use addresses; no allowlist; no explicit dangerous opt-in; and no redirect target validation.
The issue is reachable from Mercury's normal authenticated web interface.
POST /api/chat/sendaccepts user-controlled chat content and forwards it into the web channel, andCapabilityRegistryregistersfetch_urlas an exposed tool in the agent tool loop. In practice this means a web-authenticated attacker only needs to supply a prompt that causes the model to invokefetch_urlon a sensitive destination.I verified this with Mercury's real built web service and real
/api/chat/sendpath. For deterministic tool selection, the test harness used a local OpenAI-compatible stub provider, but Mercury source code, routing, tool registration, and the vulnerable network sink were not mocked or patched. During the verification run, Mercury fetchedhttp://198.18.0.1:44275/secret, the canary listener recorded one inbound request withHost: 198.18.0.1:44275, and the secret-bearing response was reflected back to assistant output. The control run changed only the target URL tohttps://example.comand produced zero local canary hits.For versioning, I verified that the vulnerable code is present in the upstream
v1.2.0tag (035b8dbfba365749491c0a3d827b828d1db25de9). The latest published GitHub release object isv1.1.13(published June 18, 2026), and it is also affected, but the canonical repository also contains the higher verified upstream tagv1.2.0(commit dated April 27, 2026). I am therefore reporting the affected range as<= 1.2.0, with occurrences pinned to that remote-validv1.2.0commit.PoC
Prerequisites
cosmicstack-labs/mercury-agentwith dependencies installed and builtdist/index.jspresent.MERCURY_HOME, logs in through the real web dashboard API, starts a local canary HTTP service, and uses a local OpenAI-compatible stub only to deterministically emit thefetch_urltool call.Reproduction Steps
python3 verification_test.py/api/auth/login, sends a chat message through/api/chat/send, and drivesfetch_urltohttp://198.18.0.1:<canary-port>/secretpython3 control-public-url.pyfetch_urlstill succeeds for the public URLLog of Evidence
Impact
This is an authenticated server-side request forgery issue that leads to internal information disclosure. Any Mercury user who can influence the agent prompt and tool selection can cause the Mercury host to send HTTP requests to special-use or otherwise non-global IPv4 targets that are reachable from the host. If those internal services return secrets, metadata, configuration, or other sensitive content, Mercury will pass that data back into assistant output. Depending on host placement, this can expose local admin panels, service discovery endpoints, cloud metadata services, or application-internal APIs.
Affected products
Severity
Weaknesses
Occurrences
mercury-agent/src/web/api/chat.ts
Lines 85 to 98 in 035b8db
/api/chat/sendaccepts user-controlled message content and forwards it into the Mercury web channel for agent processing.mercury-agent/src/capabilities/registry.ts
Lines 183 to 188 in 035b8db
CapabilityRegistryregistersfetch_url, making the network fetch primitive available inside the agent's tool loop.mercury-agent/src/capabilities/web/fetch-url.ts
Lines 44 to 64 in 035b8db
createFetchUrlTool()accepts an arbitraryurland passes it directly tofetch()with no SSRF screening for special-use, loopback, private, or otherwise non-global destinations.