Skip to content

Fix code injection via unescaped spec-derived strings in generated clients - #2808

Open
carfeii wants to merge 1 commit into
ferdikoomen:mainfrom
carfeii:fix/spec-derived-string-injection-in-generated-code
Open

Fix code injection via unescaped spec-derived strings in generated clients#2808
carfeii wants to merge 1 commit into
ferdikoomen:mainfrom
carfeii:fix/spec-derived-string-injection-in-generated-code

Conversation

@carfeii

@carfeii carfeii commented Sep 4, 2026

Copy link
Copy Markdown

See the linked issue for the vulnerability report and impact.

Root cause

Several templates interpolate OpenAPI-spec-derived strings directly into a single-quoted JS string literal in the generated output, with no escaping: the request path (url: '{{{path}}}' in exportService.hbs), each path/query/header/cookie parameter's wire name, the request-body media type, the response header name, and the first server URL / API version (client.hbs, core/OpenAPI.hbs). None of the corresponding parser values (getServices.ts's url, getServer.ts's return value, openApi.info.version, getOperationParameter.ts's parameter.name) are sanitized before reaching these templates. A single quote in any of them closes the string literal early and the remainder is evaluated as live JS.

Fix

  • New escapeSingleQuotedString Handlebars helper (registerHandlebarHelpers.ts), registered as a known helper in rollup.config.mjs.
  • Applied to the six sinks I traced end-to-end to a raw, unsanitized spec field: path, server, version, prop (parameter name), request-body mediaType, responseHeader.
  • Deliberately did not touch pattern (already escaped in getPattern.ts) or the enum name/value fields (already sanitized/pre-escaped in getEnum.ts), to avoid double-escaping a value that's already safe.

Testing

  • Re-ran both PoCs from the issue against the fixed build: the injected payload now round-trips as inert string data in the generated output (confirmed by compiling and executing the generated code - no code runs, no marker file gets written).
  • npm test: 50/50 suites, 80/80 tests, 285/285 snapshots pass, identical to before this change (the new escaping is a no-op for every existing benign fixture).
  • npm run test:e2e: the browser-driven specs (angular/xhr/fetch/babel) fail identically before and after this change in the sandbox I used ("No usable sandbox", no Chrome sandbox available in this container) - not a regression from this change, every failure is a browser-launch error, not a test assertion failure.

…ients

Several Handlebars templates interpolate OpenAPI-spec-derived strings
directly into a single-quoted JS string literal in the generated output,
with no escaping:

- exportService.hbs: the request path (`url: '{{{path}}}'`), each path/
  query/header/cookie parameter's wire name (`'{{{prop}}}': ...`), the
  request-body media type, and the response header name.
- client.hbs / core/OpenAPI.hbs: the first server URL and the API version
  (`BASE: '{{{server}}}'`, `VERSION: '{{{version}}}'`), both emitted into
  core/OpenAPI.ts, which every generated service file imports.

None of `path` (openApi.paths key, getServices.ts), `server`
(openApi.servers[0].url, getServer.ts), `version` (openApi.info.version),
or `prop` (parameter.name, getOperationParameter.ts/
getOperationRequestBody.ts) are sanitized before reaching these templates.
A single quote in any of them closes the string literal early; the
remainder becomes live JS. Two PoCs:

- A path of `/users/'+require('fs').writeFileSync('/tmp/pwned','x')+'`
  generates a service method that runs the injected code every time it's
  called (`__request(OpenAPI, { url: '<injected>' })` is a fresh
  expression evaluated on each call).
- A `servers[0].url` of `https://x'+require('fs').writeFileSync(...)+'`
  generates `core/OpenAPI.ts` (always emitted, always imported by every
  service file) with the injected code in a module-level `const`
  assignment, so it runs on *import*, before any generated method is
  even called.

Both verified against this exact codebase: generated the client, compiled
it with tsc, and confirmed the injected code executed via a marker file
written to disk (a plain module `require()` for the second case, no
network call or method invocation needed).

Fix: escape backslash and single-quote characters (registerHandlebarHelpers.ts's
new `escapeSingleQuotedString` helper, registered as a known Handlebars
helper in rollup.config.mjs) before emitting `path`, `server`, `version`,
`prop`, the request-body media type, and the response header name into
their single-quoted string literals. Re-ran both PoCs against the fixed
build: the payload now round-trips as inert string data (the generated
`fetch()` call fails cleanly on the resulting garbage URL; the module
imports without executing anything).

Deliberately scoped to the sinks I traced end-to-end to a raw,
unsanitized spec field; did not touch `pattern` (already escaped in
getPattern.ts) or the enum `name`/`value` fields (already sanitized to a
safe identifier / pre-quoted-and-escaped in getEnum.ts), to avoid
double-escaping regressions on fields that are already safe.

Full unit suite (`npm test`): 50/50 suites, 80/80 tests, 285/285
snapshots, unchanged from before this change - the escaping is a no-op
for every existing (benign) fixture. E2E suite: the browser-driven specs
(angular/xhr/fetch/babel) all fail identically before and after this
change in this sandbox with "No usable sandbox" (no Chrome sandbox
available in this container, unrelated to the fix); no test failure in
either run comes from an assertion, only from the browser launch itself.
@carfeii

carfeii commented Sep 4, 2026

Copy link
Copy Markdown
Author

Fixes #2809.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant