Skip to content

fix(static-map): validate custom-marker URL to prevent SSRF (CWE-918)#186

Open
sebastiondev wants to merge 4 commits into
mapbox:mainfrom
sebastiondev:fix/cwe918-staticmapimagetool-ssrf-8bb3
Open

fix(static-map): validate custom-marker URL to prevent SSRF (CWE-918)#186
sebastiondev wants to merge 4 commits into
mapbox:mainfrom
sebastiondev:fix/cwe918-staticmapimagetool-ssrf-8bb3

Conversation

@sebastiondev
Copy link
Copy Markdown

Description

The StaticMapImageTool accepts a url field on custom-marker overlays. This URL is forwarded to the Mapbox Static Images API, which fetches the image server-side. Because the input was validated only as a syntactically valid URL (via Zod's .url()), an attacker — or a prompt-injected agent — could supply URLs targeting internal infrastructure:

  • https://169.254.169.254/latest/meta-data/ — AWS instance metadata (cloud credential theft)
  • https://127.0.0.1:8080/admin — localhost services
  • https://10.0.0.1/... — private network resources
  • file:///etc/passwd, gopher://... — dangerous non-HTTP schemes

The Mapbox Static Images API would then make the server-side request to these addresses on behalf of the caller. This is a Server-Side Request Forgery vulnerability (CWE-918).

Severity

Medium. The SSRF targets Mapbox's infrastructure rather than the MCP server host directly, but it can be used to probe internal services accessible from Mapbox's fetcher, exfiltrate cloud metadata credentials, or scan private networks. The attack surface is amplified by the fact that MCP tool inputs can be influenced by prompt injection in agentic workflows.

Affected code

  • src/tools/static-map-image-tool/StaticMapImageTool.input.schema.ts — the CustomMarkerOverlaySchema.url field accepted any valid URL with no host or scheme restrictions.

Data flow

  1. Agent calls get_static_map_image with overlays: [{ type: "custom-marker", url: "<attacker-controlled>" }]
  2. URL passes Zod .url() validation (any scheme, any host)
  3. URL is interpolated into the Mapbox Static Images API request path
  4. Mapbox fetches the URL server-side

Fix

This PR adds a isSafeExternalUrl() validation function (src/utils/urlSafety.ts) applied as a Zod .refine() on the custom-marker URL field. The validator:

  1. Requires https:// — blocks http:, file:, gopher:, data:, javascript: schemes
  2. Blocks loopback addresses127.0.0.0/8, ::1, localhost and variants
  3. Blocks private ranges10/8, 172.16/12, 192.168/16, fc00::/7
  4. Blocks link-local169.254/16 (which includes the cloud metadata endpoint 169.254.169.254), fe80::/10
  5. Blocks CGNAT, multicast, reserved100.64/10, 224/4+
  6. Blocks IPv4-mapped IPv6 — prevents bypasses like ::ffff:127.0.0.1

Known limitation: This is pre-DNS validation, so it does not prevent DNS rebinding attacks where a public hostname resolves to a private IP. The fix is defense-in-depth against the most common and easily automated SSRF vectors (IP literals and well-known hostnames). The PR documents this transparently.


Testing

All 728 existing tests pass, plus 2 new test files:

  • test/utils/urlSafety.test.ts — 70 lines covering all blocked categories (loopback, private, link-local, CGNAT, multicast, IPv6 ULA, IPv4-mapped IPv6, non-HTTPS schemes, malformed URLs) and boundary cases (addresses just outside private ranges that should be allowed)
  • test/tools/static-map-image-tool/StaticMapImageTool.test.ts — integration test confirming the tool returns isError: true for 8 representative unsafe URLs
 Test Files  57 passed (57)
      Tests  728 passed (728)

Lint passes (npm run lint).


Proof of Concept

Before the fix, the following MCP tool call would be accepted and forwarded to Mapbox:

{
  "tool": "get_static_map_image",
  "arguments": {
    "center": { "longitude": -74.006, "latitude": 40.7128 },
    "zoom": 10,
    "size": { "width": 600, "height": 400 },
    "overlays": [
      {
        "type": "custom-marker",
        "longitude": -74.006,
        "latitude": 40.7128,
        "url": "https://169.254.169.254/latest/meta-data/iam/security-credentials/"
      }
    ]
  }
}

Mapbox's Static Images API would attempt to fetch https://169.254.169.254/latest/meta-data/iam/security-credentials/ server-side. After this fix, the Zod schema rejects the URL at input validation with a clear error message before any API call is made.

You can verify locally:

npm test -- --reporter=verbose 2>&1 | grep -A2 "rejects custom-marker URLs"

Adversarial review

Before submitting, we attempted to disprove this finding. We considered whether Mapbox's API might already reject private-range URLs on their side, but their documentation does not guarantee this — the custom marker URL is documented as accepting arbitrary image URLs. We also considered whether the MCP transport layer provides any URL filtering, but it does not; inputs are passed through as-is. The only mitigation is client-side validation before the URL reaches the API, which is what this PR adds.


Checklist

  • Tests added or updated (npm test passes)
  • Lint passes (npm run lint)
  • CHANGELOG.md updated under Unreleased
  • Documentation updated if needed (README, JSDoc)

Additional Notes

The isSafeExternalUrl utility is exported and reusable — if other tools accept user-supplied URLs in the future, they can import the same validator.


Submitted by Sebastion — autonomous open-source security research from Foundation Machines. Free for public repos via the Sebastion AI GitHub App.

The custom-marker overlay URL was forwarded to the Mapbox Static Images
API which fetches it server-side. The Zod schema only required a
syntactically valid URL (z.string().url()), so an attacker controlling
the tool input (e.g. via prompt injection) could supply http://, file://,
or URLs targeting loopback / private / link-local / cloud-metadata
addresses, turning the upstream service into an SSRF probe.

Add isSafeExternalUrl() that rejects:
  - non-https schemes
  - localhost / *.localhost / ip6-localhost
  - IPv4 loopback (127/8), unspecified (0/8), private (10/8, 172.16/12,
    192.168/16), link-local (169.254/16, incl. 169.254.169.254), CGNAT
    (100.64/10), multicast/reserved (>= 224)
  - IPv6 ::, ::1, fc00::/7, fe80::/10, ff00::/8, IPv4-mapped/compatible

Apply the validator via a Zod .refine() on CustomMarkerOverlaySchema.url.
@sebastiondev sebastiondev requested a review from a team as a code owner May 14, 2026 01:41
Copy link
Copy Markdown
Contributor

@mattpodwysocki mattpodwysocki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching this — the finding is legitimate and the implementation is largely correct. The IPv4 normalization handling, IPv6 ULA/link-local regexes, and IPv4-mapped detection all check out. A few things before we merge:

Required: additional IPv4-mapped IPv6 test cases

test/utils/urlSafety.test.ts only tests ::ffff:127.0.0.1 for the IPv4-mapped case. Please add coverage for private ranges too:

it('rejects IPv4-mapped IPv6 for private IPv4 ranges', () => {
  expect(isSafeExternalUrl('https://[::ffff:10.0.0.1]/x.png')).toBe(false);
  expect(isSafeExternalUrl('https://[::ffff:192.168.1.1]/x.png')).toBe(false);
  expect(isSafeExternalUrl('https://[::ffff:172.16.0.1]/x.png')).toBe(false);
  expect(isSafeExternalUrl('https://[::ffff:169.254.169.254]/x.png')).toBe(false);
});

These exercise the ::ffff:xxxx:xxxx regex path after Node's URL parser normalizes the dotted-quad form (e.g. ::ffff:10.0.0.1::ffff:a00:1).

Minor: add a comment on the a === 0 block

The check if (a === 0 ...) blocks all of 0.x.x.x, which is broader than strictly necessary (only 0.0.0.0 is the unspecified address). The conservative choice is fine, but a short inline comment would prevent future readers from thinking it's a bug:

// 0.0.0.0/8 — "this network" (unspecified + reserved); block conservatively
if (a === 0 || a === 10 || a === 127) return false;

Non-blocking note

The function doesn't block 192.0.2.0/24, 198.51.100.0/24, or 203.0.113.0/24 (IANA TEST-NET ranges). These are non-routable documentation ranges, so it's unlikely to matter in practice. If you want to keep scope tight, just document them as a known gap in the JSDoc — no need to add blocking logic.

Once the IPv4-mapped tests are added, happy to approve.

@sebastiondev
Copy link
Copy Markdown
Author

Thanks for the thorough review, @mattpodwysocki — all great catches.

IPv4-mapped IPv6 test cases — Added. The new test covers ::ffff:10.0.0.1, ::ffff:192.168.1.1, ::ffff:172.16.0.1, and ::ffff:169.254.169.254. All 9 tests pass.

Inline comment on a === 0 — Added the clarifying comment as suggested.

TEST-NET ranges — Agreed this is a reasonable known gap. Keeping scope tight for now; happy to document in the JSDoc if you'd like, but leaving it out of this change unless requested.

Pushed as 3ce1839.

Copy link
Copy Markdown
Contributor

@mattpodwysocki mattpodwysocki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both requested changes addressed — IPv4-mapped private range tests added and the a === 0 comment clarified. LGTM, thanks!

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.

2 participants