GHSA-8Q49-2H5H-434X

Vulnerability from github – Published: 2026-07-24 22:40 – Updated: 2026-07-24 22:40
VLAI
Summary
FrontMCP: Server-Side Request Forgery (SSRF) in the OpenAPI adapter spec-change poller
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:

// 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:

// 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

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.
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 1.5.5"
      },
      "package": {
        "ecosystem": "npm",
        "name": "@frontmcp/adapters"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "1.5.6"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-918"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-24T22:40:00Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "## Summary\n\nThe OpenAPI adapter\u0027s spec-change **poller** (`OpenApiSpecPoller`) re-fetched the\nconfigured spec `url` on a timer using a raw global `fetch()`, bypassing the SSRF\nguard (`safeFetch` / `assertUrlSafe`) that `OpenAPIToolGenerator.fromURL()` applies\nto the initial spec load. As a result, the pinning/DNS-resolution hardening delivered\nvia `mcp-from-openapi \u003e= 2.5.0` (advisory GHSA-65h7-9wrw-629c) protected the initial\nload but **not** the recurring poll of the same URL. When polling is enabled against\nan untrusted or attacker-influenceable spec URL, this is an unguarded SSRF vector.\n\n## Details\n\nThe initial spec load is guarded. `OpenapiAdapter` resolves a secure `refResolution`\npolicy and passes it to the guarded loader:\n\n```ts\n// libs/adapters/src/openapi/openapi.adapter.ts \u2014 initializeGenerator()\nreturn await OpenAPIToolGenerator.fromURL(this.options.url, {\n  // ...\n  followRedirects: this.options.loadOptions?.followRedirects ?? false,\n  refResolution, // secure default: external $refs off, internal targets blocked\n});\n```\n\nBut the poller \u2014 which re-fetches **the same URL** on every interval \u2014 did not:\n\n```ts\n// libs/adapters/src/openapi/openapi-spec-poller.ts \u2014 doFetch() (vulnerable, \u003c= 1.5.5)\nconst controller = new AbortController();\nconst timeout = setTimeout(() =\u003e controller.abort(), this.fetchTimeoutMs);\ntry {\n  const response = await fetch(this.url, {   // \u003c-- raw global fetch, no SSRF guard\n    headers,\n    signal: controller.signal,\n  });\n  // ...hash the body, fire onChanged...\n}\n```\n\nBecause `doFetch()` never called `safeFetch`, none of the guard\u0027s protections applied\nto the polled request:\n\n- no allow-list / block-list enforcement (`allowedHosts` / `blockedHosts`);\n- no internal/private/loopback/link-local/CGNAT/cloud-metadata IP blocking;\n- no DNS resolution of the hostname (so a DNS name that resolves to an internal IP,\n  e.g. `http://127.0.0.1.nip.io/`, was reached);\n- no connection **pinning** to the validated IP (DNS-rebinding TOCTOU);\n- no per-hop re-validation of HTTP redirects.\n\nThis is the identical threat model to `fromURL()` / external `$ref` resolution\n(GHSA-65h7-9wrw-629c), applied to a request path that the fix for that advisory did\nnot cover.\n\n## Impact\n\nA server that enables spec polling against an untrusted or attacker-influenceable\nspec URL will, on every poll interval, issue a server-side `GET` to whatever host the\nURL (or a DNS name it resolves to, or a redirect it returns) points at \u2014 including\ninternal-only addresses unreachable from the public internet. Consequences include:\n\n- reading cloud-instance metadata endpoints (e.g. `169.254.169.254`) \u2014 credential /\n  token theft;\n- probing and reaching internal services and private-range hosts (internal network\n  scanning);\n- DNS-rebinding to swap a public host for an internal one between validation and\n  connection.\n\nThe poller issues `GET` requests only, so the primary impact is **confidentiality**\n(reaching and reading internal endpoints); the fetched body is content-hashed to\ndetect change and the subsequent tool rebuild goes back through the guarded\n`fromURL()` path.\n\n## Preconditions\n\nExploitation requires **both**:\n\n1. `polling.enabled: true` on an `OpenapiAdapter` (polling is off by default and\n   requires the URL-based `url` option, not an inline `spec`); **and**\n2. the spec `url` is untrusted / attacker-influenceable (e.g. it is derived from user\n   input, a tenant-supplied value, or otherwise not a fixed trusted constant), or an\n   otherwise-trusted spec host is attacker-controlled or can redirect.\n\nServers that poll a fixed, trusted, first-party spec URL are not exposed in practice,\nthough they still benefit from the guard as defense-in-depth.\n\n## Proof of concept\n\n```ts\nimport { OpenapiAdapter } from \u0027@frontmcp/adapters\u0027;\n\n// url is attacker-influenceable and points (directly, via DNS, or via redirect)\n// at an internal target; polling re-fetches it every interval.\nconst adapter = OpenapiAdapter.init({\n  name: \u0027evil\u0027,\n  url: \u0027http://169.254.169.254/latest/meta-data/\u0027, // or http://127.0.0.1.nip.io/...\n  polling: { enabled: true, intervalMs: 5000 },\n});\n\nawait adapter.fetch();   // initial load IS guarded (blocked)\nadapter.startPolling();  // \u003c= 1.5.5: each poll issues an UNGUARDED GET to the internal target\n```\n\nOn `\u003c= 1.5.5` the timed poll reaches the internal address. On the patched version the\npoll fails closed (no request is made; the failure is logged) exactly as the initial\nload does.\n\n## Patch\n\nThe fix routes the poller through the same SSRF guard as the initial load, with the\nsame policy, so both paths share one DNS resolution + connection pinning and cannot\ndiverge:\n\n- `OpenApiSpecPoller.doFetch()` now calls `safeFetch(this.url, { headers, timeoutMs,\n  followRedirects, ssrf })` from `mcp-from-openapi` instead of the global `fetch()`.\n- `OpenapiAdapter.startPolling()` injects the adapter\u0027s resolved policy into the\n  poller: `ssrf: normalizeSsrfOptions(this.resolveRefResolution())` and\n  `followRedirects: loadOptions?.followRedirects ?? false` \u2014 identical to what\n  `fromURL()` receives.\n- `SpecPollerOptions` gained optional `ssrf` / `followRedirects`; standalone use of\n  `OpenApiSpecPoller` defaults to the secure policy (internal targets blocked,\n  redirects not followed).\n\nFiles changed:\n\n- `libs/adapters/src/openapi/openapi-spec-poller.ts`\n- `libs/adapters/src/openapi/openapi-spec-poller.types.ts`\n- `libs/adapters/src/openapi/openapi.adapter.ts`\n\nRequires `mcp-from-openapi \u003e= 2.5.0` (already a dependency at `2.5.1`), which exports\n`safeFetch` / `normalizeSsrfOptions` and performs the resolved-IP validation and\nconnection pinning.\n\n## Remediation\n\nUpgrade `@frontmcp/adapters` to `1.5.6` or later. No configuration change is required:\npolling now inherits the same secure defaults as the initial spec load (external\ntargets blocked, redirects not followed). To poll a genuinely internal or localhost\nspec server in a trusted environment, opt in explicitly with\n`loadOptions.refResolution.allowInternalIPs: true` \u2014 the same knob that gates the\ninitial load.\n\n## Workarounds\n\nFor users who cannot upgrade immediately:\n\n- disable polling (`polling.enabled: false`) on adapters whose spec `url` is not a\n  fixed, trusted, first-party value; or\n- only enable polling against spec URLs you fully control, served over HTTPS from a\n  host that cannot be made to redirect to internal targets; and\n- enforce network egress controls / an allow-list at the platform layer so the server\n  cannot reach internal ranges or cloud-metadata endpoints.",
  "id": "GHSA-8q49-2h5h-434x",
  "modified": "2026-07-24T22:40:00Z",
  "published": "2026-07-24T22:40:00Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/agentfront/frontmcp/security/advisories/GHSA-8q49-2h5h-434x"
    },
    {
      "type": "WEB",
      "url": "https://github.com/agentfront/frontmcp/pull/510"
    },
    {
      "type": "WEB",
      "url": "https://github.com/agentfront/frontmcp/commit/077201e109bf6f45dbc85c36d6bd77ded18ab13e"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/agentfront/frontmcp"
    },
    {
      "type": "WEB",
      "url": "https://github.com/agentfront/frontmcp/releases/tag/v1.5.6"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "FrontMCP: Server-Side Request Forgery (SSRF) in the OpenAPI adapter spec-change poller"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…