GHSA-5HXF-C7J4-279C

Vulnerability from github – Published: 2026-03-12 18:32 – Updated: 2026-03-12 18:32
VLAI?
Summary
Tina: Path Traversal in Media Upload Handle
Details

Affected Package

Field Value
Package @tinacms/cli
Version 2.0.5 (latest at time of discovery)
Vulnerable File packages/@tinacms/cli/src/next/commands/dev-command/server/media.ts
Vulnerable Lines 42-43

Summary

A path traversal vulnerability (CWE-22) exists in the TinaCMS development server's media upload handler. The code at media.ts:42-43 joins user-controlled path segments using path.join() without validating that the resulting path stays within the intended media directory. This allows writing files to arbitrary locations on the filesystem.

Attack Vector: Network (HTTP POST request)
Impact: Arbitrary file write, potential Remote Code Execution


Details

Vulnerable Code Location

File: packages/@tinacms/cli/src/next/commands/dev-command/server/media.ts
Lines: 42-43

bb.on('file', async (_name, file, _info) => {
  const fullPath = decodeURI(req.url?.slice('/media/upload/'.length));  // Line 42
  const saveTo = path.join(mediaFolder, ...fullPath.split('/'));        // Line 43
  // make sure the directory exists before writing the file
  await fs.ensureDir(path.dirname(saveTo));
  file.pipe(fs.createWriteStream(saveTo));
});

Root Cause

The path.join() function resolves .. (parent directory) segments in the path. When the user-supplied path contains traversal sequences like ../../../etc/passwd, these are resolved relative to the media folder, allowing escape to arbitrary filesystem locations.

Example:

const mediaFolder = '/app/public/uploads';
const maliciousInput = '../../../tmp/evil.txt';
const saveTo = path.join(mediaFolder, ...maliciousInput.split('/'));
// Result: '/tmp/evil.txt' - OUTSIDE the media folder!

Additional Affected Endpoints

The same vulnerability pattern exists in:

  1. Delete Handler (handleDelete, lines 29-33) - Arbitrary file deletion
  2. List Handler (handleList, lines 16-27) + MediaModel.listMedia - Directory enumeration
  3. MediaModel.deleteMedia (lines 201-217) - Arbitrary file deletion

Similar code also exists in the Express version at: - packages/@tinacms/cli/src/server/routes/index.ts - packages/@tinacms/cli/src/server/models/media.ts


PoC

Quick Verification (No Server Required)

This Node.js script directly tests the vulnerable code logic:

#!/usr/bin/env node
/**
 * TinaCMS Path Traversal Vulnerability - Direct Code Test
 * Run: node test-vulnerability.js
 */

const path = require('path');
const fs = require('fs');

// Simulated configuration (matches typical TinaCMS setup)
const rootPath = '/tmp/tinacms-test';
const publicFolder = 'public';
const mediaRoot = 'uploads';
const mediaFolder = path.join(rootPath, publicFolder, mediaRoot);

// Setup test directories
fs.mkdirSync(path.join(rootPath, publicFolder, mediaRoot), { recursive: true });
fs.mkdirSync('/tmp/target-dir', { recursive: true });

console.log(`Media folder: ${mediaFolder}`);

// Simulate vulnerable code from media.ts:42-43
function vulnerableUpload(reqUrl) {
    const fullPath = decodeURI(reqUrl.slice('/media/upload/'.length));
    const saveTo = path.join(mediaFolder, ...fullPath.split('/'));
    return saveTo;
}

// Test cases
const tests = [
    { url: '/media/upload/image.png', desc: 'Normal upload' },
    { url: '/media/upload/../../../tmp/target-dir/evil.txt', desc: 'Path traversal' },
];

tests.forEach(test => {
    const result = vulnerableUpload(test.url);
    const isVuln = !path.resolve(result).startsWith(path.resolve(mediaFolder));

    console.log(`\n${test.desc}:`);
    console.log(`  Input: ${test.url}`);
    console.log(`  Result: ${result}`);
    console.log(`  Vulnerable: ${isVuln ? 'YES ⚠️' : 'No ✓'}`);

    if (isVuln) {
        // Actually write the file to prove it works
        fs.mkdirSync(path.dirname(result), { recursive: true });
        fs.writeFileSync(result, `PWNED at ${new Date().toISOString()}`);
        console.log(`  File written: ${fs.existsSync(result)}`);
    }
});

// Cleanup
fs.rmSync(rootPath, { recursive: true, force: true });

Output

Media folder: /tmp/tinacms-test/public/uploads

Normal upload:
  Input: /media/upload/image.png
  Result: /tmp/tinacms-test/public/uploads/image.png
  Vulnerable: No ✓

Path traversal:
  Input: /media/upload/../../../tmp/target-dir/evil.txt
  Result: /tmp/tmp/target-dir/evil.txt
  Vulnerable: YES ⚠️
  File written: true

The file was successfully written to /tmp/tmp/target-dir/evil.txt, which is completely outside the intended media folder at /tmp/tinacms-test/public/uploads.

Important Note: HTTP Layer vs Code Vulnerability

I want to be transparent about my findings:

What I observed: - When testing via HTTP requests against the Vite dev server, path traversal sequences (../) are normalized by Node.js/Vite's HTTP layer before reaching the vulnerable code - This means direct HTTP exploitation like curl POST /media/upload/../../../tmp/evil.txt is mitigated in the default configuration

Why this is still a valid vulnerability that should be fixed:

  1. The code itself has no validation - If the path reaches the handler (via any vector), it will be exploited
  2. Defense-in-depth principle - Security should not rely solely on HTTP normalization
  3. Inconsistent protection - Your GraphQL layer (addPendingDocument) explicitly validates paths and rejects ../ (see test at packages/@tinacms/graphql/tests/pending-document-validation/index.test.ts:59), but the media endpoints don't have equivalent protection
  4. Different deployment contexts:
  5. Reverse proxies (nginx, Apache) with proxy_pass may preserve raw paths
  6. Custom server configurations
  7. Future refactoring that uses this code differently
  8. The parseMediaFolder helper (line 66-74) shows intent to restrict paths - the upload handler should have similar restrictions
  9. Express version also affected - packages/@tinacms/cli/src/server/routes/index.ts has the same pattern

Evidence That Path Traversal Should Be Blocked

Your codebase already shows that path traversal is considered a security issue:

// From: packages/@tinacms/graphql/tests/pending-document-validation/index.test.ts:52-70
it('handles validation error for invalid path format', async () => {
  const { query } = await setupMutation(__dirname, config);

  const invalidPathMutation = `
    mutation {
      addPendingDocument(
        collection: "post"
        relativePath: "../invalid-path.md"  // <-- Path traversal is rejected!
      ) {
        __typename
      }
    }
  `;

  const result = await query({ query: invalidPathMutation, variables: {} });

  expect(result.errors).toBeDefined();
  expect(result.errors?.length).toBeGreaterThan(0);
});

This test explicitly verifies that ../invalid-path.md is rejected in the GraphQL layer. The media upload endpoints should have the same protection.


Impact

Who is Affected

  • Developers running TinaCMS in development mode
  • Any deployment exposing the TinaCMS dev server API
  • Particularly concerning if dev servers are exposed to networks (common for mobile testing)

Potential Attack Scenarios

  1. Remote Code Execution: Write malicious files to executable locations
  2. Overwrite ~/.ssh/authorized_keys for SSH access
  3. Modify application source code
  4. Create cron jobs or systemd services

  5. Denial of Service: Delete critical application or system files

  6. Information Disclosure: List directory contents outside the media folder

CVSS Score Estimate

CVSS 3.1 Base Score: 8.1 (High) - Attack Vector: Network (AV:N) - Attack Complexity: Low (AC:L)
- Privileges Required: None (PR:N) - User Interaction: None (UI:N) - Scope: Unchanged (S:U) - Confidentiality: None (C:N) - Integrity: High (I:H) - Availability: High (A:H)


Recommended Fix

Add path validation to ensure the resolved path stays within the media directory:

import path from 'path';

const handlePost = async function (req, res) {
  const bb = busboy({ headers: req.headers });

  bb.on('file', async (_name, file, _info) => {
    const fullPath = decodeURI(req.url?.slice('/media/upload/'.length));
    const saveTo = path.join(mediaFolder, ...fullPath.split('/'));

    // ✅ SECURITY FIX: Validate path stays within media folder
    const resolvedPath = path.resolve(saveTo);
    const resolvedMediaFolder = path.resolve(mediaFolder);

    if (!resolvedPath.startsWith(resolvedMediaFolder + path.sep)) {
      res.statusCode = 403;
      res.end(JSON.stringify({ error: 'Invalid file path' }));
      return;
    }

    await fs.ensureDir(path.dirname(saveTo));
    file.pipe(fs.createWriteStream(saveTo));
  });

  // ... rest of handler
};

The same fix should be applied to: - handleDelete function - handleList function
- MediaModel.listMedia method - MediaModel.deleteMedia method - Express router in packages/@tinacms/cli/src/server/

Alternative: Create a Validation Helper

function validateMediaPath(userPath: string, mediaFolder: string): string {
  const resolved = path.resolve(path.join(mediaFolder, ...userPath.split('/')));
  const resolvedBase = path.resolve(mediaFolder);

  if (!resolved.startsWith(resolvedBase + path.sep) && resolved !== resolvedBase) {
    throw new Error('Path traversal detected');
  }

  return resolved;
}

References

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "tinacms"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "2.1.7"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-28791"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-22"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-03-12T18:32:38Z",
    "nvd_published_at": "2026-03-12T17:16:50Z",
    "severity": "HIGH"
  },
  "details": "## Affected Package\n\n| Field | Value |\n|-------|-------|\n| **Package** | `@tinacms/cli` |\n| **Version** | `2.0.5` (latest at time of discovery) |\n| **Vulnerable File** | `packages/@tinacms/cli/src/next/commands/dev-command/server/media.ts` |\n| **Vulnerable Lines** | 42-43 |\n\n---\n\n## Summary\n\nA **path traversal vulnerability (CWE-22)** exists in the TinaCMS development server\u0027s media upload handler. The code at `media.ts:42-43` joins user-controlled path segments using `path.join()` without validating that the resulting path stays within the intended media directory. This allows writing files to arbitrary locations on the filesystem.\n\n**Attack Vector**: Network (HTTP POST request)  \n**Impact**: Arbitrary file write, potential Remote Code Execution\n\n---\n\n## Details\n\n### Vulnerable Code Location\n\n**File**: `packages/@tinacms/cli/src/next/commands/dev-command/server/media.ts`  \n**Lines**: 42-43\n\n```typescript\nbb.on(\u0027file\u0027, async (_name, file, _info) =\u003e {\n  const fullPath = decodeURI(req.url?.slice(\u0027/media/upload/\u0027.length));  // Line 42\n  const saveTo = path.join(mediaFolder, ...fullPath.split(\u0027/\u0027));        // Line 43\n  // make sure the directory exists before writing the file\n  await fs.ensureDir(path.dirname(saveTo));\n  file.pipe(fs.createWriteStream(saveTo));\n});\n```\n\n### Root Cause\n\nThe `path.join()` function resolves `..` (parent directory) segments in the path. When the user-supplied path contains traversal sequences like `../../../etc/passwd`, these are resolved relative to the media folder, allowing escape to arbitrary filesystem locations.\n\n**Example**:\n```javascript\nconst mediaFolder = \u0027/app/public/uploads\u0027;\nconst maliciousInput = \u0027../../../tmp/evil.txt\u0027;\nconst saveTo = path.join(mediaFolder, ...maliciousInput.split(\u0027/\u0027));\n// Result: \u0027/tmp/evil.txt\u0027 - OUTSIDE the media folder!\n```\n\n### Additional Affected Endpoints\n\nThe same vulnerability pattern exists in:\n\n1. **Delete Handler** (`handleDelete`, lines 29-33) - Arbitrary file deletion\n2. **List Handler** (`handleList`, lines 16-27) + `MediaModel.listMedia` - Directory enumeration\n3. **MediaModel.deleteMedia** (lines 201-217) - Arbitrary file deletion\n\nSimilar code also exists in the Express version at:\n- `packages/@tinacms/cli/src/server/routes/index.ts`\n- `packages/@tinacms/cli/src/server/models/media.ts`\n\n---\n\n## PoC\n\n### Quick Verification (No Server Required)\n\nThis Node.js script directly tests the vulnerable code logic:\n\n```javascript\n#!/usr/bin/env node\n/**\n * TinaCMS Path Traversal Vulnerability - Direct Code Test\n * Run: node test-vulnerability.js\n */\n\nconst path = require(\u0027path\u0027);\nconst fs = require(\u0027fs\u0027);\n\n// Simulated configuration (matches typical TinaCMS setup)\nconst rootPath = \u0027/tmp/tinacms-test\u0027;\nconst publicFolder = \u0027public\u0027;\nconst mediaRoot = \u0027uploads\u0027;\nconst mediaFolder = path.join(rootPath, publicFolder, mediaRoot);\n\n// Setup test directories\nfs.mkdirSync(path.join(rootPath, publicFolder, mediaRoot), { recursive: true });\nfs.mkdirSync(\u0027/tmp/target-dir\u0027, { recursive: true });\n\nconsole.log(`Media folder: ${mediaFolder}`);\n\n// Simulate vulnerable code from media.ts:42-43\nfunction vulnerableUpload(reqUrl) {\n    const fullPath = decodeURI(reqUrl.slice(\u0027/media/upload/\u0027.length));\n    const saveTo = path.join(mediaFolder, ...fullPath.split(\u0027/\u0027));\n    return saveTo;\n}\n\n// Test cases\nconst tests = [\n    { url: \u0027/media/upload/image.png\u0027, desc: \u0027Normal upload\u0027 },\n    { url: \u0027/media/upload/../../../tmp/target-dir/evil.txt\u0027, desc: \u0027Path traversal\u0027 },\n];\n\ntests.forEach(test =\u003e {\n    const result = vulnerableUpload(test.url);\n    const isVuln = !path.resolve(result).startsWith(path.resolve(mediaFolder));\n    \n    console.log(`\\n${test.desc}:`);\n    console.log(`  Input: ${test.url}`);\n    console.log(`  Result: ${result}`);\n    console.log(`  Vulnerable: ${isVuln ? \u0027YES \u26a0\ufe0f\u0027 : \u0027No \u2713\u0027}`);\n    \n    if (isVuln) {\n        // Actually write the file to prove it works\n        fs.mkdirSync(path.dirname(result), { recursive: true });\n        fs.writeFileSync(result, `PWNED at ${new Date().toISOString()}`);\n        console.log(`  File written: ${fs.existsSync(result)}`);\n    }\n});\n\n// Cleanup\nfs.rmSync(rootPath, { recursive: true, force: true });\n```\n\n### Output\n\n```\nMedia folder: /tmp/tinacms-test/public/uploads\n\nNormal upload:\n  Input: /media/upload/image.png\n  Result: /tmp/tinacms-test/public/uploads/image.png\n  Vulnerable: No \u2713\n\nPath traversal:\n  Input: /media/upload/../../../tmp/target-dir/evil.txt\n  Result: /tmp/tmp/target-dir/evil.txt\n  Vulnerable: YES \u26a0\ufe0f\n  File written: true\n```\n\nThe file was successfully written to `/tmp/tmp/target-dir/evil.txt`, which is **completely outside** the intended media folder at `/tmp/tinacms-test/public/uploads`.\n\n### Important Note: HTTP Layer vs Code Vulnerability\n\nI want to be transparent about my findings:\n\n**What I observed:**\n- When testing via HTTP requests against the Vite dev server, path traversal sequences (`../`) are normalized by Node.js/Vite\u0027s HTTP layer *before* reaching the vulnerable code\n- This means direct HTTP exploitation like `curl POST /media/upload/../../../tmp/evil.txt` is mitigated in the default configuration\n\n**Why this is still a valid vulnerability that should be fixed:**\n\n1. **The code itself has no validation** - If the path reaches the handler (via any vector), it will be exploited\n2. **Defense-in-depth principle** - Security should not rely solely on HTTP normalization\n3. **Inconsistent protection** - Your GraphQL layer (`addPendingDocument`) explicitly validates paths and rejects `../` (see test at `packages/@tinacms/graphql/tests/pending-document-validation/index.test.ts:59`), but the media endpoints don\u0027t have equivalent protection\n4. **Different deployment contexts**:\n   - Reverse proxies (nginx, Apache) with `proxy_pass` may preserve raw paths\n   - Custom server configurations\n   - Future refactoring that uses this code differently\n5. **The `parseMediaFolder` helper** (line 66-74) shows intent to restrict paths - the upload handler should have similar restrictions\n6. **Express version also affected** - `packages/@tinacms/cli/src/server/routes/index.ts` has the same pattern\n\n---\n\n### Evidence That Path Traversal Should Be Blocked\n\nYour codebase already shows that path traversal is considered a security issue:\n\n```typescript\n// From: packages/@tinacms/graphql/tests/pending-document-validation/index.test.ts:52-70\nit(\u0027handles validation error for invalid path format\u0027, async () =\u003e {\n  const { query } = await setupMutation(__dirname, config);\n\n  const invalidPathMutation = `\n    mutation {\n      addPendingDocument(\n        collection: \"post\"\n        relativePath: \"../invalid-path.md\"  // \u003c-- Path traversal is rejected!\n      ) {\n        __typename\n      }\n    }\n  `;\n\n  const result = await query({ query: invalidPathMutation, variables: {} });\n\n  expect(result.errors).toBeDefined();\n  expect(result.errors?.length).toBeGreaterThan(0);\n});\n```\n\nThis test explicitly verifies that `../invalid-path.md` is rejected in the GraphQL layer. The media upload endpoints should have the same protection.\n\n---\n\n## Impact\n\n### Who is Affected\n\n- Developers running TinaCMS in development mode\n- Any deployment exposing the TinaCMS dev server API\n- Particularly concerning if dev servers are exposed to networks (common for mobile testing)\n\n### Potential Attack Scenarios\n\n1. **Remote Code Execution**: Write malicious files to executable locations\n   - Overwrite `~/.ssh/authorized_keys` for SSH access\n   - Modify application source code\n   - Create cron jobs or systemd services\n\n2. **Denial of Service**: Delete critical application or system files\n\n3. **Information Disclosure**: List directory contents outside the media folder\n\n### CVSS Score Estimate\n\n**CVSS 3.1 Base Score: 8.1 (High)**\n- Attack Vector: Network (AV:N)\n- Attack Complexity: Low (AC:L)  \n- Privileges Required: None (PR:N)\n- User Interaction: None (UI:N)\n- Scope: Unchanged (S:U)\n- Confidentiality: None (C:N)\n- Integrity: High (I:H)\n- Availability: High (A:H)\n\n---\n\n## Recommended Fix\n\nAdd path validation to ensure the resolved path stays within the media directory:\n\n```typescript\nimport path from \u0027path\u0027;\n\nconst handlePost = async function (req, res) {\n  const bb = busboy({ headers: req.headers });\n\n  bb.on(\u0027file\u0027, async (_name, file, _info) =\u003e {\n    const fullPath = decodeURI(req.url?.slice(\u0027/media/upload/\u0027.length));\n    const saveTo = path.join(mediaFolder, ...fullPath.split(\u0027/\u0027));\n\n    // \u2705 SECURITY FIX: Validate path stays within media folder\n    const resolvedPath = path.resolve(saveTo);\n    const resolvedMediaFolder = path.resolve(mediaFolder);\n\n    if (!resolvedPath.startsWith(resolvedMediaFolder + path.sep)) {\n      res.statusCode = 403;\n      res.end(JSON.stringify({ error: \u0027Invalid file path\u0027 }));\n      return;\n    }\n\n    await fs.ensureDir(path.dirname(saveTo));\n    file.pipe(fs.createWriteStream(saveTo));\n  });\n  \n  // ... rest of handler\n};\n```\n\nThe same fix should be applied to:\n- `handleDelete` function\n- `handleList` function  \n- `MediaModel.listMedia` method\n- `MediaModel.deleteMedia` method\n- Express router in `packages/@tinacms/cli/src/server/`\n\n### Alternative: Create a Validation Helper\n\n```typescript\nfunction validateMediaPath(userPath: string, mediaFolder: string): string {\n  const resolved = path.resolve(path.join(mediaFolder, ...userPath.split(\u0027/\u0027)));\n  const resolvedBase = path.resolve(mediaFolder);\n  \n  if (!resolved.startsWith(resolvedBase + path.sep) \u0026\u0026 resolved !== resolvedBase) {\n    throw new Error(\u0027Path traversal detected\u0027);\n  }\n  \n  return resolved;\n}\n```\n\n---\n\n## References\n\n- [CWE-22: Improper Limitation of a Pathname to a Restricted Directory (\u0027Path Traversal\u0027)](https://cwe.mitre.org/data/definitions/22.html)\n- [OWASP Path Traversal](https://owasp.org/www-community/attacks/Path_Traversal)\n- [Node.js path.join() Documentation](https://nodejs.org/api/path.html#pathjoinpaths)\n- [OWASP Testing Guide - Path Traversal](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/05-Authorization_Testing/01-Testing_Directory_Traversal_File_Include)",
  "id": "GHSA-5hxf-c7j4-279c",
  "modified": "2026-03-12T18:32:38Z",
  "published": "2026-03-12T18:32:38Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/tinacms/tinacms/security/advisories/GHSA-5hxf-c7j4-279c"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-28791"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/tinacms/tinacms"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Tina: Path Traversal in Media Upload Handle"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Sightings

Author Source Type Date

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…