GHSA-g9c2-gf25-3x67 @tinacms/graphql's `FilesystemBridge` Path Validation Can Be Bypassed via Symlinks or Junctions
Summary`@tinacms/graphql` uses string-based path containment checks in `FilesystemBridge`:`path.resolve(path.join(baseDir, filepath))``startsWith(resolvedBase + path.sep)`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.DetailsThe current bridge validation is:But the bridge then performs real filesystem I/O on the resulting path:This is a classic realpath gap:validation checks the lexical path stringthe filesystem follows the link target during I/Othe actual target can be outside the intended rootThis is reachable from Tina's GraphQL/local database flow. The resolver builds a validated path from user-controlled `relativePath`, but that validation is also string-based:Database write and delete operations then call the bridge:Local ReproductionThis was verified llocally with a real junction on Windows, which exercises the same failure mode as a symlink on Unix-like systems.Test layout:content root: `D:\bugcrowd\tinacms\temp\junction-repro4`allowed collection path: `content/posts`junction inside collection: `content/posts/pivot -> D:\bugcrowd\tinacms\temp\junction-repro4\outside`file outside content root: `outside\secret.txt`Tina's current path-validation logic was applied and used to perform bridge-style read/write operations through the junction.Observed result:That is the critical point:the path was accepted as inside `content/posts`the bridge read `outside\secret.txt`the bridge wrote `outside\owned.md`So the current containment check does not actually constrain filesystem access to the configured content root once a link exists inside that tree.Impact**Arbitrary file read/write outside the configured content root****Potential delete outside the configured content root** via the same `assertWithinBase()` gap in `delete()`**Breaks the assumptions of the recent path-traversal fixes** because only lexical traversal is blocked**Practical attack chains** where the content tree contains a committed symlink/junction, or an attacker can cause one to exist before issuing GraphQL/content operationsThe exact network exploitability depends on how the application exposes Tina's GraphQL/content operations, but the underlying bridge bug is real and independently security-relevant.Recommended FixThe containment check needs to compare canonical filesystem paths, not just string-normalized paths.For example:resolve the base with `fs.realpath()`resolve the candidate path's parent with `fs.realpath()`reject any request whose real target path escapes the real basefor write operations, carefully canonicalize the nearest existing parent directory before creating the final fileIn short: use realpath-aware containment checks for every filesystem sink, not `path.resolve(...).startsWith(...)` alone.Resources`packages/@tinacms/graphql/src/database/bridge/filesystem.ts``packages/@tinacms/graphql/src/database/index.ts``packages/@tinacms/graphql/src/resolver/index.ts`
CVSS v3.1 score: 7.1