GitHub Security Advisory: GHSA-g87c-r2jp-293w
Package: @tinacms/graphql (NPM)
Severity: HIGH (CVSS 7.1)
CWE: CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal'), CWE-59: Improper Link Resolution Before File Access ('Link Following')
Vulnerable versions: <= 2.2.1
Patched in: 2.2.2

Summary: @tinacms/graphql's Media Endpoints Can Escape the Media Root via Symlinks or Junctions

Details:
## Summary

`@tinacms/cli` recently added lexical path-traversal checks to the dev media routes, but the implementation still validates only the path string and does not resolve symlink or junction targets.

If a link already exists under the media root, Tina accepts a path like `pivot/written-from-media.txt` as "inside" the media directory and then performs real filesystem operations through that link target. This allows out-of-root media listing and write access, and the same root cause also affects delete.

## Details

The dev media handlers validate user-controlled paths with:

```ts
function resolveWithinBase(userPath: string, baseDir: string): string {
  const resolvedBase = path.resolve(baseDir);
  const resolved = path.resolve(path.join(baseDir, userPath));
  if (resolved === resolvedBase) {
    return resolvedBase;
  }
  if (resolved.startsWith(resolvedBase + path.sep)) {
    return resolved;
  }
  throw new PathTraversalError(userPath);
}

function resolveStrictlyWithinBase(userPath: string, baseDir: string): string {
  const resolvedBase = path.resolve(baseDir) + path.sep;
  const resolved = path.resolve(path.join(baseDir, userPath));
  if (!resolved.startsWith(resolvedBase)) {
    throw new PathTraversalError(userPath);
  }
  return resolved;
}
```

But the validated path is then used directly for real filesystem access:

```ts
filesStr = await fs.readdir(validatedPath);
...
await fs.ensureDir(path.dirname(saveTo));
file.pipe(fs.createWriteStream(saveTo));
...
await fs.remove(file);
```

This does not account for symlinks/junctions already present below the media root. A path such as `pivot/secret.txt` can be lexically inside the media directory while the filesystem target is outside it.

## Local Reproduction

I verified this locally with a real junction on Windows.

Test layout:

- media root: `D:\bugcrowd\tinacms\temp\junction-repro4\public\uploads`
- junction under media root: `public\uploads\pivot -> D:\bugcrowd\tinacms\temp\junction-repro4\outside`
- file outside the media root: `outside\secret.txt`

Tina's current media-path validation logic was applied and used to perform the same list/write operations the route handlers use.

Observed result:

```json
{
  "media": {
    "base": "D:\\bugcrowd\\tinacms\\temp\\junction-repro4\\public\\uploads",
    "resolvedListPath": "D:\\bugcrowd\\tinacms\\temp\\junction-repro4\\public\\uploads\\pivot",
    "listedEntries": [
      "secret.txt"
    ],
    "resolvedWritePath": "D:\\bugcrowd\\tinacms\\temp\\junction-repro4\\public\\uploads\\pivot\\written-from-media.txt",
    "outsideWriteExists": true,
    "outsideWriteContents": "MEDIA_ESCAPE"
  }
}
```

This shows the problem clearly:

- the path validator accepted `pivot`
- listing revealed a file from outside the media root
- writing to `pivot/written-from-media.txt` created `outside\written-from-media.txt`

The delete path uses the same flawed containment model and should be hardened at the same time.

## Impact

- **Out-of-root file listi