GitHub Security Advisory: GHSA-g9c2-gf25-3x67
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.0
Patched in: 2.2.2

Summary: @tinacms/graphql's `FilesystemBridge` Path Validation Can Be Bypassed via Symlinks or Junctions

Details:
## Summary

`@tinacms/graphql` uses string-based path containment checks in `FilesystemBridge`:

- `path.resolve(path.join(baseDir, filepath))`
- `startsWith(resolvedBase + path.sep)`

That blocks plain `../` traversal, but it does not resolve symlink or junction targets. If a symlink/junction already exists under the allowed content root, a path like `content/posts/pivot/owned.md` is still considered "inside" the base even though the real filesystem target can be outside it.

As a result, `FilesystemBridge.get()`, `put()`, `delete()`, and `glob()` can operate on files outside the intended root.

## Details

The current bridge validation is:

```ts
function assertWithinBase(filepath: string, baseDir: string): string {
  const resolvedBase = path.resolve(baseDir);
  const resolved = path.resolve(path.join(baseDir, filepath));
  if (
    resolved !== resolvedBase &&
    !resolved.startsWith(resolvedBase + path.sep)
  ) {
    throw new Error(
      `Path traversal detected: "${filepath}" escapes the base directory`
    );
  }
  return resolved;
}
```

But the bridge then performs real filesystem I/O on the resulting path:

```ts
public async get(filepath: string) {
  const resolved = assertWithinBase(filepath, this.outputPath);
  return (await fs.readFile(resolved)).toString();
}

public async put(filepath: string, data: string, basePathOverride?: string) {
  const basePath = basePathOverride || this.outputPath;
  const resolved = assertWithinBase(filepath, basePath);
  await fs.outputFile(resolved, data);
}

public async delete(filepath: string) {
  const resolved = assertWithinBase(filepath, this.outputPath);
  await fs.remove(resolved);
}
```

This is a classic realpath gap:

1. validation checks the lexical path string
2. the filesystem follows the link target during I/O
3. the actual target can be outside the intended root

This is reachable from Tina's GraphQL/local database flow. The resolver builds a validated path from user-controlled `relativePath`, but that validation is also string-based:

```ts
const realPath = path.join(collection.path, relativePath);
this.validatePath(realPath, collection, relativePath);
```

Database write and delete operations then call the bridge:

```ts
await this.bridge.put(normalizedPath, stringifiedFile);
...
await this.bridge.delete(normalizedPath);
```

## Local Reproduction

This was verified llocally with a real junction on Windows, which exercises the same failure mode as a symlink on Unix-like systems.

Test layout:

- content root: `D:\bugcrowd\tinacms\temp\junction-repro4`
- allowed collection path: `content/posts`
- junction inside collection: `content/posts/pivot -> D:\bugcrowd\tinacms\temp\junction-repro4\outside`
- file outside content root: `outside\secret.txt`

Tina's current path-validation logic was applied and used to perform bridge-style read/write operations through the junction.

Observed result:

```json
{
  "graphqlBridge": {
    "collectionPath": "content/posts",
    "requestedRelativePat