GHSA-8q49-2h5h-434x
FrontMCP: Server-Side Request Forgery (SSRF) in the OpenAPI adapter spec-change poller
Quick fix
GHSA-8q49-2h5h-434x — @frontmcp/adapters: upgrade to the fixed version with the command below.
npm install @frontmcp/adapters@1.5.6 Details
## Summary
The OpenAPI adapter's spec-change **poller** (`OpenApiSpecPoller`) re-fetched the configured spec `url` on a timer using a raw global `fetch()`, bypassing the SSRF guard (`safeFetch` / `assertUrlSafe`) that `OpenAPIToolGenerator.fromURL()` applies to the initial spec load. As a result, the pinning/DNS-resolution hardening delivered via `mcp-from-openapi >= 2.5.0` (advisory GHSA-65h7-9wrw-629c) protected the initial load but **not** the recurring poll of the same URL. When polling is enabled against an untrusted or attacker-influenceable spec URL, this is an unguarded SSRF vector.
## Details
The initial spec load is guarded. `OpenapiAdapter` resolves a secure `refResolution` policy and passes it to the guarded loader:
```ts // libs/adapters/src/openapi/openapi.adapter.ts — initializeGenerator() return await OpenAPIToolGenerator.fromURL(this.options.url, { // ... followRedirects: this.options.loadOptions?.followRedirects ?? false, refResolution, // secure default: external $refs off, internal targets blocked }); ```
But the poller — which re-fetches **the same URL** on every interval — did not:
```ts // libs/adapters/src/openapi/openapi-spec-poller.ts — doFetch() (vulnerable, <= 1.5.5) const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.fetchTimeoutMs); try { const response = await fetch(this.url, { // <-- raw global fetch, no SSRF guard headers, signal: controller.signal, }); // ...hash the body, fire onChanged... } ```
Because `doFetch()` never called `safeFetch`, none of the guard's protections applied to the polled request:
- no allow-list / block-list enforcement (`allowedHosts` / `blockedHosts`); - no internal/private/loopback/link-local/CGNAT/cloud-metadata IP blocking; - no DNS resolution of the hostname (so a DNS name that resolves to an internal IP, e.g. `http://127.0.0.1.nip.io/`, was reached); - no connection **pinning** to the validated IP (DNS-rebinding TOCTOU); - no per-hop re-validation of HTTP redirects.
This is the identical threat model to `fromURL()` / external `$ref` resolution (GHSA-65h7-9wrw-629c), applied to a request path that the fix for that advisory did not cover.
## Impact
A server that enables spec polling against an untrusted or attacker-influenceable spec URL will, on every poll interval, issue a server-side `GET` to whatever host the URL (or a DNS name it resolves to, or a redirect it returns) points at — including internal-only addresses unreachable from the public internet. Consequences include:
- reading cloud-instance metadata endpoints (e.g. `169.254.169.254`) — credential / token theft; - probing and reaching internal services and private-range hosts (internal network scanning); - DNS-rebinding to swap a public host for an internal one between validation and connection.
The poller issues `GET` requests only, so the primary impact is **confidentiality** (reaching and reading internal endpoints); the fetched body is content-hashed to detect change and the subsequent tool rebuild goes back through the guarded `fromURL()` path.
## Preconditions
Exploitation requires **both**:
1. `polling.enabled: true` on an `OpenapiAdapter` (polling is off by default and requires the URL-based `url` option, not an inline `spec`); **and** 2. the spec `url` is untrusted / attacker-influenceable (e.g. it is derived from user input, a tenant-supplied value, or otherwise not a fixed trusted constant), or an otherwise-trusted spec host is attacker-controlled or can redirect.
Servers that poll a fixed, trusted, first-party spec URL are not exposed in practice, though they still benefit from the guard as defense-in-depth.
## Proof of concept
```ts import { OpenapiAdapter } from '@frontmcp/adapters';
// url is attacker-influenceable and points (directly, via DNS, or via redirect) // at an internal target; polling re-fetches it every interval. const adapter = OpenapiAdapter.init({ name: 'evil', url: 'http://169.254.169.254/latest/meta-data/', // or http://127.0.0.1.nip.io/... polling: { enabled: true, intervalMs: 5000 }, });
await adapter.fetch(); // initial load IS guarded (blocked) adapter.startPolling(); // <= 1.5.5: each poll issues an UNGUARDED GET to the internal target ```
On `<= 1.5.5` the timed poll reaches the internal address. On the patched version the poll fails closed (no request is made; the failure is logged) exactly as the initial load does.
## Patch
The fix routes the poller through the same SSRF guard as the initial load, with the same policy, so both paths share one DNS resolution + connection pinning and cannot diverge:
- `OpenApiSpecPoller.doFetch()` now calls `safeFetch(this.url, { headers, timeoutMs, followRedirects, ssrf })` from `mcp-from-openapi` instead of the global `fetch()`. - `OpenapiAdapter.startPolling()` injects the adapter's resolved policy into the poller: `ssrf: normalizeSsrfOptions(this.resolveRefResolution())` and `followRedirects: loadOptions?.followRedirects ?? false` — identical to what `fromURL()` receives. - `SpecPollerOptions` gained optional `ssrf` / `followRedirects`; standalone use of `OpenApiSpecPoller` defaults to the secure policy (internal targets blocked, redirects not followed).
Files changed:
- `libs/adapters/src/openapi/openapi-spec-poller.ts` - `libs/adapters/src/openapi/openapi-spec-poller.types.ts` - `libs/adapters/src/openapi/openapi.adapter.ts`
Requires `mcp-from-openapi >= 2.5.0` (already a dependency at `2.5.1`), which exports `safeFetch` / `normalizeSsrfOptions` and performs the resolved-IP validation and connection pinning.
## Remediation
Upgrade `@frontmcp/adapters` to `1.5.6` or later. No configuration change is required: polling now inherits the same secure defaults as the initial spec load (external targets blocked, redirects not followed). To poll a genuinely internal or localhost spec server in a trusted environment, opt in explicitly with `loadOptions.refResolution.allowInternalIPs: true` — the same knob that gates the initial load.
## Workarounds
For users who cannot upgrade immediately:
- disable polling (`polling.enabled: false`) on adapters whose spec `url` is not a fixed, trusted, first-party value; or - only enable polling against spec URLs you fully control, served over HTTPS from a host that cannot be made to redirect to internal targets; and - enforce network egress controls / an allow-list at the platform layer so the server cannot reach internal ranges or cloud-metadata endpoints.
Are you affected?
Enter the version of the package you're using.
Affected packages
References
- https://github.com/agentfront/frontmcp/security/advisories/GHSA-8q49-2h5h-434x [WEB]
- https://github.com/agentfront/frontmcp/pull/510 [WEB]
- https://github.com/agentfront/frontmcp/commit/077201e109bf6f45dbc85c36d6bd77ded18ab13e [WEB]
- https://github.com/agentfront/frontmcp [PACKAGE]
- https://github.com/agentfront/frontmcp/releases/tag/v1.5.6 [WEB]