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

CVE-2026-34603

CVE-2026-34603

Description

Tina is a headless content management system. Prior to version 2.2.2, @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. 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
  • cpe:2.3:a:ssw:tinacms\/cli:*:*:*:*:*:node.js:*:*
    Range: <=2.2.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.