VYPR
High severity7.1NVD Advisory· Published Apr 1, 2026· Updated Apr 7, 2026

CVE-2026-34604

CVE-2026-34604

Description

Tina is a headless content management system. Prior to version 2.2.2, @tinacms/graphql uses string-based path containment checks in FilesystemBridge. 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. This issue has been patched in version 2.2.2.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
@tinacms/graphqlnpm
< 2.2.22.2.2

Affected products

1

Patches

1
f124eabaca10

Fix symlink path traversal bypass in media endpoints and FilesystemBridge (#6552)

https://github.com/tinacms/tinacmsMatt Wicks [SSW]Mar 26, 2026via ghsa
13 files changed · +424 0
  • .changeset/four-wombats-kiss.md+5 0 added
    @@ -0,0 +1,5 @@
    +---
    +"@tinacms/cli": patch
    +---
    +
    +feat: add warning in dev command when server is not restricted to localhost
    
  • .changeset/gold-rivers-destroy.md+5 0 added
    @@ -0,0 +1,5 @@
    +---
    +"@tinacms/graphql": patch
    +---
    +
    +Fix symlink/junction path traversal bypass (GHSA-g87c-r2jp-293w, GHSA-g9c2-gf25-3x67)
    
  • packages/@tinacms/cli/src/next/commands/dev-command/index.ts+7 0 modified
    @@ -6,6 +6,7 @@ import chokidar from 'chokidar';
     import { Command, Option } from 'clipanion';
     import fs from 'fs-extra';
     import { logger, summary } from '../../../logger';
    +import { isHostExposed } from '../../../utils/host';
     import { spin } from '../../../utils/spinner';
     import { dangerText, warnText } from '../../../utils/theme';
     import { Codegen } from '../../codegen';
    @@ -245,6 +246,12 @@ export class DevCommand extends BaseCommand {
         );
         await server.listen(Number(this.port));
     
    +    if (isHostExposed(server.config.server.host)) {
    +      logger.warn(
    +        '⚠️  The TinaCMS dev server is listening on a non-localhost address. It has no authentication and is not intended to be exposed to the internet.'
    +      );
    +    }
    +
         if (!this.noWatch) {
           chokidar.watch(configManager.watchList).on('change', async () => {
             await dbLock(async () => {
    
  • packages/@tinacms/cli/src/next/commands/dev-command/server/media.test.ts+33 0 modified
    @@ -60,6 +60,39 @@ describe('MediaModel (Vite dev server)', () => {
         });
       });
     
    +  describe('symlink traversal', () => {
    +    let outsideDir: string;
    +
    +    beforeEach(async () => {
    +      outsideDir = path.join(
    +        process.env.TMPDIR || '/tmp',
    +        `tina-outside-vite-${Date.now()}`
    +      );
    +      await fs.mkdirp(outsideDir);
    +      await fs.writeFile(path.join(outsideDir, 'secret.txt'), 'sensitive');
    +      const mediaDir = path.join(tmpDir, 'public', 'uploads');
    +      await fs.symlink(outsideDir, path.join(mediaDir, 'escape'));
    +    });
    +
    +    afterEach(async () => {
    +      await fs.remove(outsideDir);
    +    });
    +
    +    it('listMedia rejects symlink escaping media root', async () => {
    +      const model = new MediaModel(config);
    +      await expect(model.listMedia({ searchPath: 'escape' })).rejects.toThrow(
    +        PathTraversalError
    +      );
    +    });
    +
    +    it('deleteMedia rejects symlink escaping media root', async () => {
    +      const model = new MediaModel(config);
    +      await expect(
    +        model.deleteMedia({ searchPath: 'escape/secret.txt' })
    +      ).rejects.toThrow(PathTraversalError);
    +    });
    +  });
    +
       describe('deleteMedia', () => {
         it('deletes a valid file', async () => {
           const mediaDir = path.join(tmpDir, 'public', 'uploads');
    
  • packages/@tinacms/cli/src/next/commands/dev-command/server/media.ts+58 0 modified
    @@ -171,6 +171,61 @@ type SuccessRecord = { ok: true } | { ok: false; message: string };
      */
     const ENCODED_TRAVERSAL_RE = /%2e%2e|%2f|%5c/i;
     
    +/**
    + * Follows symlinks to determine where a path actually points on disk.
    + *
    + * If the full path exists, returns its `fs.realpathSync` result. If it
    + * doesn't (e.g. a file that will be created by a write/upload), walks up
    + * the directory tree until it finds an ancestor that does exist, resolves
    + * that ancestor's real path, and re-appends the remaining segments.
    + *
    + * @security INLINED for CodeQL taint-tracking (see module-level comment).
    + * @param candidate - An absolute path that may or may not exist on disk.
    + * @returns The real (symlink-resolved) absolute path.
    + */
    +function resolveRealPath(candidate: string): string {
    +  try {
    +    return fs.realpathSync(candidate);
    +  } catch {
    +    const parent = path.dirname(candidate);
    +    if (parent === candidate) return candidate;
    +    return path.join(resolveRealPath(parent), path.basename(candidate));
    +  }
    +}
    +
    +/**
    + * Verifies that a path doesn't escape the base directory via symlinks or
    + * junctions (CWE-59). Resolves both the base and the candidate to their
    + * real filesystem locations, then checks containment.
    + *
    + * Silently skips the check if the base directory doesn't exist on disk
    + * (the caller's lexical check is sufficient in that case since no real
    + * I/O can occur).
    + *
    + * @security INLINED for CodeQL taint-tracking (see module-level comment).
    + * @param resolved     - The lexically-validated absolute path.
    + * @param resolvedBase - The absolute base directory (without trailing sep).
    + * @param userPath     - The original untrusted input (for error messages).
    + */
    +function assertSymlinkWithinBase(
    +  resolved: string,
    +  resolvedBase: string,
    +  userPath: string
    +): void {
    +  try {
    +    const realBase = fs.realpathSync(resolvedBase);
    +    const realResolved = resolveRealPath(resolved);
    +    if (
    +      realResolved !== realBase &&
    +      !realResolved.startsWith(realBase + path.sep)
    +    ) {
    +      throw new PathTraversalError(userPath);
    +    }
    +  } catch (err) {
    +    if (err instanceof PathTraversalError) throw err;
    +  }
    +}
    +
     /**
      * Resolve `userPath` against `baseDir` and verify it falls within the base.
      * Allows an exact match (returns the base itself) or a subdirectory.
    @@ -194,9 +249,11 @@ function resolveWithinBase(userPath: string, baseDir: string): string {
       const resolvedBase = path.resolve(baseDir);
       const resolved = path.resolve(path.join(baseDir, userPath));
       if (resolved === resolvedBase) {
    +    assertSymlinkWithinBase(resolved, resolvedBase, userPath);
         return resolvedBase;
       }
       if (resolved.startsWith(resolvedBase + path.sep)) {
    +    assertSymlinkWithinBase(resolved, resolvedBase, userPath);
         return resolved;
       }
       throw new PathTraversalError(userPath);
    @@ -222,6 +279,7 @@ function resolveStrictlyWithinBase(userPath: string, baseDir: string): string {
       if (!resolved.startsWith(resolvedBase)) {
         throw new PathTraversalError(userPath);
       }
    +  assertSymlinkWithinBase(resolved, path.resolve(baseDir), userPath);
       return resolved;
     }
     
    
  • packages/@tinacms/cli/src/server/models/media.test.ts+33 0 modified
    @@ -58,6 +58,39 @@ describe('MediaModel (Express server)', () => {
         });
       });
     
    +  describe('symlink traversal', () => {
    +    let outsideDir: string;
    +
    +    beforeEach(async () => {
    +      outsideDir = path.join(
    +        process.env.TMPDIR || '/tmp',
    +        `tina-outside-express-${Date.now()}`
    +      );
    +      await fs.mkdirp(outsideDir);
    +      await fs.writeFile(path.join(outsideDir, 'secret.txt'), 'sensitive');
    +      const mediaDir = path.join(tmpDir, 'public', 'uploads');
    +      await fs.symlink(outsideDir, path.join(mediaDir, 'escape'));
    +    });
    +
    +    afterEach(async () => {
    +      await fs.remove(outsideDir);
    +    });
    +
    +    it('listMedia rejects symlink escaping media root', async () => {
    +      const model = new MediaModel(config);
    +      await expect(model.listMedia({ searchPath: 'escape' })).rejects.toThrow(
    +        PathTraversalError
    +      );
    +    });
    +
    +    it('deleteMedia rejects symlink escaping media root', async () => {
    +      const model = new MediaModel(config);
    +      await expect(
    +        model.deleteMedia({ searchPath: 'escape/secret.txt' })
    +      ).rejects.toThrow(PathTraversalError);
    +    });
    +  });
    +
       describe('deleteMedia', () => {
         it('deletes a valid file', async () => {
           const mediaDir = path.join(tmpDir, 'public', 'uploads');
    
  • packages/@tinacms/cli/src/server/models/media.ts+58 0 modified
    @@ -57,6 +57,61 @@ export interface PathConfig {
      */
     const ENCODED_TRAVERSAL_RE = /%2e%2e|%2f|%5c/i;
     
    +/**
    + * Follows symlinks to determine where a path actually points on disk.
    + *
    + * If the full path exists, returns its `fs.realpathSync` result. If it
    + * doesn't (e.g. a file that will be created by a write/upload), walks up
    + * the directory tree until it finds an ancestor that does exist, resolves
    + * that ancestor's real path, and re-appends the remaining segments.
    + *
    + * @security INLINED for CodeQL taint-tracking (see module-level comment).
    + * @param candidate - An absolute path that may or may not exist on disk.
    + * @returns The real (symlink-resolved) absolute path.
    + */
    +function resolveRealPath(candidate: string): string {
    +  try {
    +    return fs.realpathSync(candidate);
    +  } catch {
    +    const parent = path.dirname(candidate);
    +    if (parent === candidate) return candidate;
    +    return path.join(resolveRealPath(parent), path.basename(candidate));
    +  }
    +}
    +
    +/**
    + * Verifies that a path doesn't escape the base directory via symlinks or
    + * junctions (CWE-59). Resolves both the base and the candidate to their
    + * real filesystem locations, then checks containment.
    + *
    + * Silently skips the check if the base directory doesn't exist on disk
    + * (the caller's lexical check is sufficient in that case since no real
    + * I/O can occur).
    + *
    + * @security INLINED for CodeQL taint-tracking (see module-level comment).
    + * @param resolved     - The lexically-validated absolute path.
    + * @param resolvedBase - The absolute base directory (without trailing sep).
    + * @param userPath     - The original untrusted input (for error messages).
    + */
    +function assertSymlinkWithinBase(
    +  resolved: string,
    +  resolvedBase: string,
    +  userPath: string
    +): void {
    +  try {
    +    const realBase = fs.realpathSync(resolvedBase);
    +    const realResolved = resolveRealPath(resolved);
    +    if (
    +      realResolved !== realBase &&
    +      !realResolved.startsWith(realBase + path.sep)
    +    ) {
    +      throw new PathTraversalError(userPath);
    +    }
    +  } catch (err) {
    +    if (err instanceof PathTraversalError) throw err;
    +  }
    +}
    +
     /**
      * Resolve `userPath` against `baseDir` and verify it falls within the base.
      * Allows an exact match (returns the base itself) or a subdirectory.
    @@ -80,9 +135,11 @@ function resolveWithinBase(userPath: string, baseDir: string): string {
       const resolvedBase = path.resolve(baseDir);
       const resolved = path.resolve(path.join(baseDir, userPath));
       if (resolved === resolvedBase) {
    +    assertSymlinkWithinBase(resolved, resolvedBase, userPath);
         return resolvedBase;
       }
       if (resolved.startsWith(resolvedBase + path.sep)) {
    +    assertSymlinkWithinBase(resolved, resolvedBase, userPath);
         return resolved;
       }
       throw new PathTraversalError(userPath);
    @@ -108,6 +165,7 @@ function resolveStrictlyWithinBase(userPath: string, baseDir: string): string {
       if (!resolved.startsWith(resolvedBase)) {
         throw new PathTraversalError(userPath);
       }
    +  assertSymlinkWithinBase(resolved, path.resolve(baseDir), userPath);
       return resolved;
     }
     
    
  • packages/@tinacms/cli/src/utils/host.test.ts+35 0 added
    @@ -0,0 +1,35 @@
    +import { isHostExposed } from './host';
    +
    +describe('isHostExposed', () => {
    +  it('returns false for undefined (default)', () => {
    +    expect(isHostExposed(undefined)).toBe(false);
    +  });
    +
    +  it('returns false for false (Vite default = localhost)', () => {
    +    expect(isHostExposed(false)).toBe(false);
    +  });
    +
    +  it('returns false for "localhost"', () => {
    +    expect(isHostExposed('localhost')).toBe(false);
    +  });
    +
    +  it('returns false for "127.0.0.1"', () => {
    +    expect(isHostExposed('127.0.0.1')).toBe(false);
    +  });
    +
    +  it('returns false for "::1"', () => {
    +    expect(isHostExposed('::1')).toBe(false);
    +  });
    +
    +  it('returns true for true (listen on all interfaces)', () => {
    +    expect(isHostExposed(true)).toBe(true);
    +  });
    +
    +  it('returns true for "0.0.0.0"', () => {
    +    expect(isHostExposed('0.0.0.0')).toBe(true);
    +  });
    +
    +  it('returns true for a LAN IP', () => {
    +    expect(isHostExposed('192.168.1.100')).toBe(true);
    +  });
    +});
    
  • packages/@tinacms/cli/src/utils/host.ts+12 0 added
    @@ -0,0 +1,12 @@
    +const LOCALHOST_ADDRESSES = ['localhost', '127.0.0.1', '::1'];
    +
    +/**
    + * Returns true when the Vite server host config indicates the server is
    + * listening on a non-localhost address (i.e. exposed to the network).
    + */
    +export function isHostExposed(host: string | boolean | undefined): boolean {
    +  if (host === true) return true;
    +  if (typeof host === 'string' && !LOCALHOST_ADDRESSES.includes(host))
    +    return true;
    +  return false;
    +}
    
  • packages/@tinacms/cli/src/utils/path.test.ts+53 0 modified
    @@ -89,6 +89,59 @@ describe('stripNativeTrailingSlash under POSIX/UNIX', () => {
       });
     });
     
    +describe('assertPathWithinBase (symlink traversal)', () => {
    +  let assertPathWithinBase: typeof import('./path').assertPathWithinBase;
    +  let PathTraversalError: typeof import('./path').PathTraversalError;
    +  let tmpDir: string;
    +  let outsideDir: string;
    +
    +  beforeAll(() => {
    +    const mod = require('./path');
    +    assertPathWithinBase = mod.assertPathWithinBase;
    +    PathTraversalError = mod.PathTraversalError;
    +  });
    +
    +  beforeEach(() => {
    +    const os = require('os');
    +    const fs = require('fs');
    +    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tina-path-symlink-'));
    +    outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tina-outside-'));
    +    fs.writeFileSync(path.join(outsideDir, 'secret.txt'), 'sensitive');
    +    // symlink: tmpDir/escape -> outsideDir
    +    fs.symlinkSync(outsideDir, path.join(tmpDir, 'escape'));
    +  });
    +
    +  afterEach(() => {
    +    const fs = require('fs');
    +    fs.rmSync(tmpDir, { recursive: true, force: true });
    +    fs.rmSync(outsideDir, { recursive: true, force: true });
    +  });
    +
    +  it('rejects path through symlink escaping base (existing file)', () => {
    +    expect(() => assertPathWithinBase('escape/secret.txt', tmpDir)).toThrow(
    +      PathTraversalError
    +    );
    +  });
    +
    +  it('rejects path through symlink escaping base (non-existing file)', () => {
    +    expect(() => assertPathWithinBase('escape/newfile.txt', tmpDir)).toThrow(
    +      PathTraversalError
    +    );
    +  });
    +
    +  it('allows symlink that stays within base', () => {
    +    const fs = require('fs');
    +    const subDir = path.join(tmpDir, 'real-sub');
    +    fs.mkdirSync(subDir);
    +    fs.writeFileSync(path.join(subDir, 'ok.txt'), 'safe');
    +    fs.symlinkSync(subDir, path.join(tmpDir, 'link-to-sub'));
    +
    +    expect(() =>
    +      assertPathWithinBase('link-to-sub/ok.txt', tmpDir)
    +    ).not.toThrow();
    +  });
    +});
    +
     describe('assertPathWithinBase', () => {
       const baseDir = '/app/public/uploads';
     
    
  • packages/@tinacms/cli/src/utils/path.ts+43 0 modified
    @@ -51,6 +51,7 @@
      *
      * @module
      */
    +import fs from 'fs';
     import path from 'path';
     
     /** Removes trailing slash from path. Separator to remove is chosen based on
    @@ -83,6 +84,29 @@ export function stripNativeTrailingSlash(p: string) {
      */
     const ENCODED_TRAVERSAL_RE = /%2e%2e|%2f|%5c/i;
     
    +/**
    + * Follows symlinks to determine where a path actually points on disk.
    + *
    + * If the full path exists, returns its `fs.realpathSync` result. If it
    + * doesn't (e.g. a file that will be created by a write operation), walks
    + * up the directory tree until it finds an ancestor that does exist,
    + * resolves that ancestor's real path, and re-appends the remaining
    + * segments. This lets us detect symlink escapes even for paths that
    + * haven't been written yet.
    + *
    + * @param candidate - An absolute path that may or may not exist on disk.
    + * @returns The real (symlink-resolved) absolute path.
    + */
    +function resolveRealPath(candidate: string): string {
    +  try {
    +    return fs.realpathSync(candidate);
    +  } catch {
    +    const parent = path.dirname(candidate);
    +    if (parent === candidate) return candidate;
    +    return path.join(resolveRealPath(parent), path.basename(candidate));
    +  }
    +}
    +
     /**
      * Validates that a user-supplied path does not escape the base directory
      * via path traversal (CWE-22). Returns the resolved absolute path.
    @@ -126,6 +150,25 @@ export function assertPathWithinBase(
         throw new PathTraversalError(userPath);
       }
     
    +  // Symlink/junction check: resolve real filesystem paths and re-validate
    +  // containment. This catches cases where a symlink inside the base directory
    +  // points to a location outside it (CWE-59).
    +  // Wrapped in try-catch: if the base dir doesn't exist on disk, the lexical
    +  // check above is sufficient (no real I/O can happen anyway).
    +  try {
    +    const realBase = fs.realpathSync(resolvedBase);
    +    const realResolved = resolveRealPath(resolved);
    +    if (
    +      realResolved !== realBase &&
    +      !realResolved.startsWith(realBase + path.sep)
    +    ) {
    +      throw new PathTraversalError(userPath);
    +    }
    +  } catch (err) {
    +    if (err instanceof PathTraversalError) throw err;
    +    // Base dir doesn't exist — lexical check is sufficient
    +  }
    +
       return resolved;
     }
     
    
  • packages/@tinacms/graphql/src/database/bridge/filesystem.ts+41 0 modified
    @@ -4,6 +4,29 @@ import fs from 'fs-extra';
     import normalize from 'normalize-path';
     import type { Bridge } from './index';
     
    +/**
    + * Follows symlinks to determine where a path actually points on disk.
    + *
    + * If the full path exists, returns its `fs.realpathSync` result. If it
    + * doesn't (e.g. a file that will be created by a `put` operation), walks
    + * up the directory tree until it finds an ancestor that does exist,
    + * resolves that ancestor's real path, and re-appends the remaining
    + * segments. This lets us detect symlink escapes even for paths that
    + * haven't been written yet.
    + *
    + * @param candidate - An absolute path that may or may not exist on disk.
    + * @returns The real (symlink-resolved) absolute path.
    + */
    +function resolveRealPath(candidate: string): string {
    +  try {
    +    return fs.realpathSync(candidate);
    +  } catch {
    +    const parent = path.dirname(candidate);
    +    if (parent === candidate) return candidate;
    +    return path.join(resolveRealPath(parent), path.basename(candidate));
    +  }
    +}
    +
     /**
      * Defense-in-depth: validates that a filepath stays within a base directory.
      * This protects against CWE-22 (Path Traversal) even if callers fail to
    @@ -34,6 +57,24 @@ function assertWithinBase(filepath: string, baseDir: string): string {
           `Path traversal detected: "${filepath}" escapes the base directory`
         );
       }
    +
    +  // Symlink/junction check (CWE-59)
    +  try {
    +    const realBase = fs.realpathSync(resolvedBase);
    +    const realResolved = resolveRealPath(resolved);
    +    if (
    +      realResolved !== realBase &&
    +      !realResolved.startsWith(realBase + path.sep)
    +    ) {
    +      throw new Error(
    +        `Path traversal detected: "${filepath}" escapes the base directory`
    +      );
    +    }
    +  } catch (err) {
    +    if (err instanceof Error && err.message.startsWith('Path traversal'))
    +      throw err;
    +  }
    +
       return resolved;
     }
     
    
  • packages/@tinacms/graphql/tests/filesystem-bridge/index.test.ts+41 0 modified
    @@ -1,3 +1,4 @@
    +import os from 'os';
     import path from 'path';
     import fs from 'fs-extra';
     import { afterEach, beforeEach, describe, expect, test } from 'vitest';
    @@ -99,6 +100,46 @@ describe('filesystem bridge', () => {
           );
         });
       });
    +
    +  describe('symlink traversal', () => {
    +    let tmpDir: string;
    +    let outsideDir: string;
    +
    +    beforeEach(async () => {
    +      tmpDir = path.join(os.tmpdir(), `tina-bridge-symlink-${Date.now()}`);
    +      outsideDir = path.join(os.tmpdir(), `tina-bridge-outside-${Date.now()}`);
    +      await fs.mkdirp(tmpDir);
    +      await fs.mkdirp(outsideDir);
    +      await fs.writeFile(path.join(outsideDir, 'secret.txt'), 'sensitive');
    +      await fs.symlink(outsideDir, path.join(tmpDir, 'escape'));
    +    });
    +
    +    afterEach(async () => {
    +      await fs.remove(tmpDir);
    +      await fs.remove(outsideDir);
    +    });
    +
    +    test('get rejects symlink escaping base', async () => {
    +      const bridge = new FilesystemBridge(tmpDir);
    +      await expect(bridge.get('escape/secret.txt')).rejects.toThrow(
    +        'Path traversal detected'
    +      );
    +    });
    +
    +    test('put rejects symlink escaping base', async () => {
    +      const bridge = new FilesystemBridge(tmpDir);
    +      await expect(bridge.put('escape/newfile.txt', 'payload')).rejects.toThrow(
    +        'Path traversal detected'
    +      );
    +    });
    +
    +    test('delete rejects symlink escaping base', async () => {
    +      const bridge = new FilesystemBridge(tmpDir);
    +      await expect(bridge.delete('escape/secret.txt')).rejects.toThrow(
    +        'Path traversal detected'
    +      );
    +    });
    +  });
     });
     
     describe('AuditFileSystemBridge', () => {
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.