fix(static-map): validate custom-marker URL to prevent SSRF (CWE-918)#186
fix(static-map): validate custom-marker URL to prevent SSRF (CWE-918)#186sebastiondev wants to merge 4 commits into
Conversation
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.
mattpodwysocki
left a comment
There was a problem hiding this comment.
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.
|
Thanks for the thorough review, @mattpodwysocki — all great catches. IPv4-mapped IPv6 test cases — Added. The new test covers Inline comment on 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 |
mattpodwysocki
left a comment
There was a problem hiding this comment.
Both requested changes addressed — IPv4-mapped private range tests added and the a === 0 comment clarified. LGTM, thanks!
Description
The
StaticMapImageToolaccepts aurlfield 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 serviceshttps://10.0.0.1/...— private network resourcesfile:///etc/passwd,gopher://...— dangerous non-HTTP schemesThe 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— theCustomMarkerOverlaySchema.urlfield accepted any valid URL with no host or scheme restrictions.Data flow
get_static_map_imagewithoverlays: [{ type: "custom-marker", url: "<attacker-controlled>" }].url()validation (any scheme, any host)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:https://— blockshttp:,file:,gopher:,data:,javascript:schemes127.0.0.0/8,::1,localhostand variants10/8,172.16/12,192.168/16,fc00::/7169.254/16(which includes the cloud metadata endpoint169.254.169.254),fe80::/10100.64/10,224/4+::ffff:127.0.0.1Known 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 returnsisError: truefor 8 representative unsafe URLsLint 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:
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
npm testpasses)npm run lint)CHANGELOG.mdupdated underUnreleasedAdditional Notes
The
isSafeExternalUrlutility 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.