{"id":"GHSA-2q42-4q24-7rgv","aliases":[],"url":"https://o3.security/vulnerability/GHSA-2q42-4q24-7rgv","summary":"OpenAPI3 version value escapes `emitterOutputDir` and overwrites YAML/JSON outside the output tree","details":"### Summary\n\nThe `@typespec/openapi3` emitter retains the value of a `@versioned` enum member and interpolates it into the output filename as `{version}` without sanitizing path separators or traversal components. The completed path reaches the compiler's `emitFile()`, which creates the parent directory and writes the file without verifying containment under `emitterOutputDir`.\n\nA crafted declarative `.tsp` input can therefore create or overwrite an OpenAPI-formatted `.yaml` or `.json` file outside the configured output tree, subject to the compiler process's filesystem permissions. No executable TypeSpec extension or attacker-controlled JavaScript is required.\n\n### Affected version\n\nConfirmed on:\n\n- `@typespec/compiler` `1.15.0`\n- `@typespec/openapi3` `1.15.0`\n- `@typespec/http` `1.15.0`\n- `@typespec/versioning` `0.85.0`\n- release tag commit `f30cd352f93997e04c75d48c7ace6947a1d5d07a`\n\nThe critical `openapi.ts` and `emitter-utils.ts` blobs are unchanged on main commit `365ec52b50b82cd9e1e037de4c6fcd5de7e32e90` as checked on 2026-08-19. No patched version was identified.\n\n### Root cause\n\nThe value originates at `packages/openapi3/src/openapi.ts:592-608`:\n\n```ts\nserviceRecord.versions.push({\n  service,\n  version: snapshot.version!.value,\n  document: document[0],\n  diagnostics: document[1],\n});\n```\n\nIt is interpolated without path validation at `openapi.ts:629-641`:\n\n```ts\nreturn interpolatePath(options.outputFile, {\n  \"openapi-version\": specVersion,\n  \"service-name-if-multiple\": multipleService ? getNamespaceFullName(service.type) : undefined,\n  \"service-name\": getNamespaceFullName(service.type),\n  \"file-type\": fileType,\n  version,\n});\n```\n\nThe path reaches `emitFile()` at `openapi.ts:392-401`. The sink at `packages/compiler/src/core/emitter-utils.ts:29-39` performs no output-root containment check:\n\n```ts\nconst outputFolder = getDirectoryPath(options.path);\nawait program.host.mkdirp(outputFolder);\nreturn await program.host.writeFile(options.path, content);\n```\n\n`resolvePath()` joins the template to `emitterOutputDir` before `{version}` is interpolated, so it does not see the attacker-controlled `..` components.\n\n### Proof of concept\n\nThe relevant input in `poc/main.tsp` is:\n\n```tsp\n@versioned(Versions) namespace Svc;\nenum Versions { v1: \"../../../../../../../../../../tmp/TYPESPEC_PWNED/pwn\" }\n```\n\nRun from PowerShell:\n\n```powershell\ncd poc\n.\\run-revalidation.ps1\n```\n\nThe supplied Docker runner uses a digest-pinned Node base, a committed npm integrity lock, disabled runtime networking, a benign control, a 90-second timeout, and cleanup enforcement.\n\nObserved in the preserved identity-locked replay (`1/1` attack and `1/1` negative control):\n\n```text\nnegative_before=02dc7d056c2f773e56e2c1849947888b039f127ddd630c0bd76a5d7a9ca29cbd\nnegative_after=02dc7d056c2f773e56e2c1849947888b039f127ddd630c0bd76a5d7a9ca29cbd\nattack_before=7a8067bc04e42a025de90fd7aff9be4df59f005d192f2116eecfb107d7bffd78\nattack_after=2992b399c1573c9bd2130794f8554c4026bf425861f74e78b5d89ae0324b5e38\noutside_file_head=openapi: 3.0.0\ninside_attack_files=\n```\n\nThe benign version preserved the outside canary. The crafted version replaced it with emitted OpenAPI content, and no attack output file remained under `tsp-output`.\n\n### Impact and constraints\n\nProven impact is out-of-directory YAML/JSON creation or overwrite. The attacker can influence the traversal, final basename, and many strings in the OpenAPI-structured content. The extension is constrained to the selected emitter format, content is not arbitrary bytes, and the target must be writable.\n\nThis report demonstrates file corruption. Denial of service is a potential impact when a writable critical file is targeted. It does not claim file disclosure, arbitrary-byte write, deployment takeover, or code execution by a downstream consumer.\n\n### Suggested remediation\n\nAfter every filename token has been interpolated, resolve the completed destination against `emitterOutputDir` and reject any non-descendant using path-component-aware comparison. Reject or slugify absolute paths, path separators, and traversal components in spec-derived filename tokens. Add regression cases for POSIX and Windows separators, absolute values, sibling-prefix paths, and benign semantic versions.","published":"2026-09-08T21:31:09Z","modified":"2026-09-08T21:45:04.607149521Z","cvss":{"score":7.1,"severity":"HIGH","vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"@typespec/openapi3","fixedVersion":null},{"ecosystem":"npm","name":"@typespec/compiler","fixedVersion":null}],"fix":{"url":"https://github.com/microsoft/typespec/pull/11777","label":"microsoft/typespec#11777"},"references":[{"type":"WEB","url":"https://github.com/microsoft/typespec/security/advisories/GHSA-2q42-4q24-7rgv"},{"type":"WEB","url":"https://github.com/microsoft/typespec/pull/11777"},{"type":"WEB","url":"https://github.com/microsoft/typespec/commit/e0f67bdf3c5a0875dfa98b475648af37caac71a6"},{"type":"PACKAGE","url":"https://github.com/microsoft/typespec"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-09-08T21:45:04.607149521Z"}}