@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.
The dev media handlers validate user-controlled paths with:
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;
}
But the validated path is then used directly for real filesystem access:
filesStr = await fs.readdir(validatedPath);
...
await fs.ensureDir(path.dirname(saveTo));
file.pipe(fs.createWriteStream(saveTo));
...
await fs.remove(file);
This does not account for symlinks/junctions already present below the media root. A path such as pivot/secret.txt can be lexically inside the media directory while the filesystem target is outside it.
I verified this locally with a real junction on Windows.
Test layout:
D:\bugcrowd\tinacms\temp\junction-repro4\public\uploadspublic\uploads\pivot -> D:\bugcrowd\tinacms\temp\junction-repro4\outsideoutside\secret.txtTina's current media-path validation logic was applied and used to perform the same list/write operations the route handlers use.
Observed result:
{
"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"
}
}
This shows the problem clearly:
pivotpivot/written-from-media.txt created outside\written-from-media.txtThe delete path uses the same flawed containment model and should be hardened at the same time.
/media/list/.../media/upload/.../media/... DELETE, using the same path-validation gapThis is especially relevant in development and self-hosted workflows where the media directory may contain symlinks or junctions intentionally or via repository content.
Harden media path validation with canonical filesystem checks:
fs.realpath()path.resolve(...).startsWith(...) is not sufficient for filesystem security on linked paths.
packages/@tinacms/cli/src/next/commands/dev-command/server/media.tspackages/@tinacms/cli/src/server/models/media.tspackages/@tinacms/cli/src/utils/path.ts{
"cwe_ids": [
"CWE-22",
"CWE-59"
],
"github_reviewed": true,
"nvd_published_at": null,
"severity": "HIGH",
"github_reviewed_at": "2026-04-01T00:23:02Z"
}