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.
| Package | Affected versions | Patched versions |
|---|---|---|
@tinacms/graphqlnpm | < 2.2.2 | 2.2.2 |
Affected products
1Patches
1f124eabaca10Fix symlink path traversal bypass in media endpoints and FilesystemBridge (#6552)
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- github.com/tinacms/tinacms/commit/f124eabaca10dac9a4d765c9e4135813c4830955nvdPatchWEB
- github.com/advisories/GHSA-g9c2-gf25-3x67ghsaADVISORY
- github.com/tinacms/tinacms/security/advisories/GHSA-g9c2-gf25-3x67nvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-34604ghsaADVISORY
News mentions
0No linked articles in our index yet.