TinaCMS’s dev media endpoints in @tinacms/cli and @tinacms/graphql suffer from a symlink and junction traversal vulnerability. Recent lexical path-traversal fixes check only string paths, ignoring filesystem links already under the media root. Attackers can list, write, and delete files outside the root if a symlink or junction points there.
This hits the /media/list, /media/upload, and delete routes on the local dev server. A path like pivot/secret.txt passes validation if pivot is a junction to an external directory, but operations dereference to the target. Verified on Windows with junctions; symlinks work similarly on Unix.
Vulnerability Mechanics
TinaCMS updated its media handlers with these functions:
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;
}
These rely on Node’s path.resolve, which doesn’t follow symlinks—it resolves lexically. The validated path then feeds fs.readdir, fs.createWriteStream, and fs.remove, which do follow links.
Result: A symlink uploads/pivot -> /etc lets /media/list/pivot/passwd dump /etc/passwd. Writes create files in the target. Deletes wipe them.
Proof of Concept
Repro on Windows (junctions act like symlinks for directories):
- Media root:
D:\bugcrowd\tinacms\temp\junction-repro4\public\uploads - Junction:
uploads\pivot -> D:\bugcrowd\tinacms\temp\junction-repro4\outside - Outside file:
outside\secret.txt
Tina validates pivot as inside, lists secret.txt, writes pivot/written-from-media.txt (lands in outside), confirms contents match.
{
"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"
}
}
Unix symlinks reproduce identically. No auth on dev server, so local network access or shared dev envs expose it.
Why This Matters and Fixes
TinaCMS powers Git-based CMS for Next.js, Gatsby, etc. Dev servers run on localhost:3000 by default, but devs often expose via ngrok, Tailscale, or misbind to 0.0.0.0. An attacker on the LAN—or worse, internet—uploads a symlink, then escapes to read ~/.ssh/id_rsa, env, source code, or npmrc tokens. Writes drop ransomware or backdoors. Deletes nuke repos.
Real-world risk: Dev workflows share previews. Tools like Vercel or Netlify dev proxies amplify if misconfigured. No CVSS yet, but high—arbitrary R/W outside sandbox.
Fixes demand symlink-aware checks. Use fs.realpathSync post-resolution:
const realTarget = fs.realpathSync(resolvedPath);
if (!realTarget.startsWith(realBase + path.sep)) {
throw new Error('Path outside base after symlink resolution');
}
Or fs.lstatSync to detect/deny links. Canonicalize fully. Tina patched lexical ../ recently—good start, but incomplete. Devs: Audit media roots, block symlinks at upload, run dev servers firewalled.
Update @tinacms/cli ASAP; watch GitHub for patch. This exposes a classic pitfall: path.resolve ≠ secure containment. Test your stacks—symlinks bite everyone eventually.