Cross-Origin File Exfiltration via CORS Misconfiguration + Path Traversal in TinaCMS
Description
Tina is a headless content management system. Prior to 2.1.8 , the TinaCMS CLI dev server combines a permissive CORS configuration (Access-Control-Allow-Origin: *) with the path traversal vulnerability (previously reported) to enable a browser-based drive-by attack. A remote attacker can enumerate the filesystem, write arbitrary files, and delete arbitrary files on developer's machines by simply tricking them into visiting a malicious website while tinacms dev is running. This vulnerability is fixed in 2.1.8.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
TinaCMS CLI dev server prior to 2.1.8 had permissive CORS and path traversal, enabling remote file read/write/delete via browser drive-by attack.
Vulnerability
Overview
The TinaCMS CLI dev server prior to version 2.1.8 combined a permissive CORS configuration (Access-Control-Allow-Origin: *) with a path traversal vulnerability, allowing a browser-based drive-by attack on developer machines [1][4]. The CORS misconfiguration permitted any website to make cross-origin requests to the dev server, while the path traversal bug allowed accessing files outside the intended directory [4].
Attack
Vector
An attacker can exploit this by tricking a developer into visiting a malicious website while tinacms dev is running locally (default port 4001) [1]. The malicious site’s JavaScript can make fetch requests to http://localhost:4001/../../../etc/passwd (or similar paths), leveraging the CORS wildcard to read and exfiltrate sensitive files, write arbitrary files, or delete files [4]. No network exposure is required—the attack succeeds purely from within the browser on the same machine [4].
Impact
A remote attacker can enumerate the filesystem, write arbitrary files, and delete arbitrary files on the developer's machine [1][4]. This includes exfiltration of source code, credentials, and other sensitive data, potentially leading to supply chain compromise if malicious files are written to the project [4].
Mitigation
The vulnerability is fixed in TinaCMS version 2.1.8 [1]. The fix restricts CORS to localhost by default, adds a server.allowedOrigins config option for non-localhost environments, and enables Vite’s server.fs.strict with a computed allow list [2][3]. Users should upgrade immediately and avoid running tinacms dev while browsing untrusted sites if upgrading is not possible [4].
AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@tinacms/clinpm | < 2.1.8 | 2.1.8 |
Affected products
2- @tinacms/cliv5Range: < 2.1.8
Patches
156d533e610a5Restricted CORS and Vite fs access on dev server (#6450)
10 files changed · +370 −48
.changeset/nasty-eels-compete.md+9 −0 added@@ -0,0 +1,9 @@ +--- +"@tinacms/cli": patch +"@tinacms/schema-tools": patch +--- + +* Restricted CORS on dev server to localhost by default for [GHSA-8pw3-9m7f-q734](https://github.com/tinacms/tinacms/security/advisories/GHSA-8pw3-9m7f-q734). +* Added `server.allowedOrigins` config option for non-localhost environments. +* Enabled Vite `server.fs.strict` with computed allow list for [GHSA-m48g-4wr2-j2h6](https://github.com/tinacms/tinacms/security/advisories/GHSA-m48g-4wr2-j2h6) +* Bind LevelDB TCP server to 127.0.0.1
packages/@tinacms/cli/src/cmds/init/templates/config.ts+6 −0 modified@@ -138,6 +138,12 @@ export const generateConfig = (args: ConfigTemplateArgs) => { outputFolder: "admin", publicFolder: "${args.publicFolder}", }, + // Uncomment to allow cross-origin requests from non-localhost origins + // during local development (e.g. GitHub Codespaces, Gitpod, Docker). + // Use 'private' to allow all private-network IPs (WSL2, Docker, etc.) + // server: { + // allowedOrigins: ['https://your-codespace.github.dev'], + // }, media: { tina: { mediaRoot: "",
packages/@tinacms/cli/src/next/database.ts+9 −9 modified@@ -1,20 +1,20 @@ +import { createServer } from 'net'; import { - createDatabaseInternal, - FilesystemBridge, + Bridge, Database, + FilesystemBridge, TinaLevelClient, - Bridge, + createDatabaseInternal, } from '@tinacms/graphql'; +import { ManyLevelHost } from 'many-level'; +import { MemoryLevel } from 'memory-level'; +import { pipeline } from 'readable-stream'; +import { logger } from '../logger'; import { ConfigManager, LEGACY_TINA_FOLDER, TINA_FOLDER, } from './config-manager'; -import { logger } from '../logger'; -import { pipeline } from 'readable-stream'; -import { createServer } from 'net'; -import { ManyLevelHost } from 'many-level'; -import { MemoryLevel } from 'memory-level'; export const createDBServer = (port: number) => { const levelHost = new ManyLevelHost( @@ -37,7 +37,7 @@ export const createDBServer = (port: number) => { ); } }); - dbServer.listen(port); + dbServer.listen(port, 'localhost'); }; export async function createAndInitializeDatabase(
packages/@tinacms/cli/src/next/vite/cors.test.ts+126 −0 added@@ -0,0 +1,126 @@ +import { buildCorsOriginCheck } from './cors'; + +describe('buildCorsOriginCheck', () => { + const check = ( + fn: ReturnType<typeof buildCorsOriginCheck>, + origin: string | undefined + ): Promise<boolean> => + new Promise((resolve) => { + fn(origin, (_err, allow) => resolve(!!allow)); + }); + + describe('default (no allowedOrigins)', () => { + const fn = buildCorsOriginCheck(); + + it('allows requests with no Origin header', async () => { + expect(await check(fn, undefined)).toBe(true); + }); + + it('allows http://localhost', async () => { + expect(await check(fn, 'http://localhost')).toBe(true); + }); + + it('allows http://localhost:3000', async () => { + expect(await check(fn, 'http://localhost:3000')).toBe(true); + }); + + it('allows http://127.0.0.1:4001', async () => { + expect(await check(fn, 'http://127.0.0.1:4001')).toBe(true); + }); + + it('allows http://[::1]:4001', async () => { + expect(await check(fn, 'http://[::1]:4001')).toBe(true); + }); + + it('blocks https://evil.com', async () => { + expect(await check(fn, 'https://evil.com')).toBe(false); + }); + + it('blocks http://172.20.0.5:3000 (private IP without keyword)', async () => { + expect(await check(fn, 'http://172.20.0.5:3000')).toBe(false); + }); + }); + + describe('with exact origin strings', () => { + const fn = buildCorsOriginCheck(['https://my-codespace.github.dev']); + + it('allows the configured origin', async () => { + expect(await check(fn, 'https://my-codespace.github.dev')).toBe(true); + }); + + it('still allows localhost', async () => { + expect(await check(fn, 'http://localhost:3000')).toBe(true); + }); + + it('blocks other origins', async () => { + expect(await check(fn, 'https://evil.com')).toBe(false); + }); + }); + + describe('with "private" keyword', () => { + const fn = buildCorsOriginCheck(['private']); + + it('allows 10.x.x.x (RFC 1918)', async () => { + expect(await check(fn, 'http://10.0.0.1:3000')).toBe(true); + }); + + it('allows 172.16-31.x.x (RFC 1918)', async () => { + expect(await check(fn, 'http://172.20.0.5:4001')).toBe(true); + }); + + it('allows 192.168.x.x (RFC 1918)', async () => { + expect(await check(fn, 'http://192.168.1.100:3000')).toBe(true); + }); + + it('blocks 172.15.x.x (not RFC 1918)', async () => { + expect(await check(fn, 'http://172.15.0.1:3000')).toBe(false); + }); + + it('blocks 172.32.x.x (not RFC 1918)', async () => { + expect(await check(fn, 'http://172.32.0.1:3000')).toBe(false); + }); + + it('blocks public IPs', async () => { + expect(await check(fn, 'http://8.8.8.8:3000')).toBe(false); + }); + + it('blocks domains', async () => { + expect(await check(fn, 'https://evil.com')).toBe(false); + }); + + it('still allows localhost', async () => { + expect(await check(fn, 'http://localhost:3000')).toBe(true); + }); + }); + + describe('with "private" keyword plus extra origins', () => { + const fn = buildCorsOriginCheck([ + 'private', + 'https://my-codespace.github.dev', + ]); + + it('allows private IPs', async () => { + expect(await check(fn, 'http://192.168.1.5:3000')).toBe(true); + }); + + it('allows the extra origin', async () => { + expect(await check(fn, 'https://my-codespace.github.dev')).toBe(true); + }); + + it('blocks other origins', async () => { + expect(await check(fn, 'https://evil.com')).toBe(false); + }); + }); + + describe('with RegExp entries', () => { + const fn = buildCorsOriginCheck([/^https:\/\/.*\.preview\.app$/]); + + it('allows matching origins', async () => { + expect(await check(fn, 'https://foo.preview.app')).toBe(true); + }); + + it('blocks non-matching origins', async () => { + expect(await check(fn, 'https://evil.com')).toBe(false); + }); + }); +});
packages/@tinacms/cli/src/next/vite/cors.ts+64 −0 added@@ -0,0 +1,64 @@ +/** + * Shared CORS origin-checking logic for the TinaCMS dev server. + * + * By default only localhost / 127.0.0.1 / [::1] (any port) are allowed. + * Users can extend this via `server.allowedOrigins` in their tina config. + * The special keyword `'private'` expands to all RFC 1918 private-network + * IP ranges (10.x, 172.16-31.x, 192.168.x) — useful for WSL2, Docker + * bridge networks, etc. + */ + +const LOCALHOST_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/; + +// RFC 1918 private networks: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 +const PRIVATE_NETWORK_RE = + /^https?:\/\/(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})(:\d+)?$/; + +/** + * Expand a raw `allowedOrigins` array: replace the `'private'` keyword + * with the RFC 1918 regex and keep everything else as-is. + */ +function expandOrigins(raw: (string | RegExp)[]): (string | RegExp)[] { + const hasPrivate = raw.some((o) => o === 'private'); + const filtered = raw.filter((o) => o !== 'private'); + return hasPrivate ? [...filtered, PRIVATE_NETWORK_RE] : filtered; +} + +/** + * Build a CORS `origin` callback compatible with the `cors` npm package. + */ +export function buildCorsOriginCheck( + allowedOrigins: (string | RegExp)[] = [] +): ( + origin: string | undefined, + callback: (err: Error | null, allow?: boolean) => void +) => void { + const extra = expandOrigins(allowedOrigins); + + return (origin, callback) => { + // Allow requests with no Origin header (curl, same-origin, etc.). + if (!origin) { + callback(null, true); + return; + } + if (LOCALHOST_RE.test(origin)) { + callback(null, true); + return; + } + for (const allowed of extra) { + if (typeof allowed === 'string') { + if (allowed === origin) { + callback(null, true); + return; + } + } else { + allowed.lastIndex = 0; + if (allowed.test(origin)) { + callback(null, true); + return; + } + } + } + callback(null, false); + }; +}
packages/@tinacms/cli/src/next/vite/index.test.ts+81 −21 modified@@ -16,10 +16,10 @@ jest.mock('vite', () => ({ splitVendorChunkPlugin: () => ({ name: 'mock-split-vendor' }), })); -import { createConfig } from './index'; -import * as filterPublicEnvModule from './filterPublicEnv'; -import type { ConfigManager } from '../config-manager'; import type { Database } from '@tinacms/graphql'; +import type { ConfigManager } from '../config-manager'; +import * as filterPublicEnvModule from './filterPublicEnv'; +import { createConfig } from './index'; /** Minimal stub satisfying the properties createConfig reads. */ function stubConfigManager(): ConfigManager { @@ -65,7 +65,7 @@ const FAKE_PUBLIC_ENV = { HEAD: 'main', }; -describe('createConfig integration', () => { +describe('createConfig', () => { let spy: jest.SpyInstance; beforeAll(() => { @@ -78,57 +78,117 @@ describe('createConfig integration', () => { spy.mockRestore(); }); - it('calls filterPublicEnv', async () => { - await createConfig({ + it('embeds the filterPublicEnv result into define["process.env"]', async () => { + const config = await createConfig({ configManager: stubConfigManager(), database: {} as Database, apiURL: 'http://localhost:4001/graphql', noWatch: true, }); + const raw = config.define!['process.env']; - expect(spy).toHaveBeenCalled(); + expect(raw).toBe(`new Object(${JSON.stringify(FAKE_PUBLIC_ENV)})`); }); - it('embeds the filterPublicEnv result into define["process.env"]', async () => { + it('does not embed any values outside the filterPublicEnv result', async () => { + spy.mockReturnValue({ TINA_PUBLIC_ONLY: 'safe' }); + const config = await createConfig({ configManager: stubConfigManager(), database: {} as Database, apiURL: 'http://localhost:4001/graphql', noWatch: true, }); const raw = config.define!['process.env']; + const jsonStr = raw.replace(/^new Object\(/, '').replace(/\)$/, ''); + const env = JSON.parse(jsonStr); - expect(raw).toBe(`new Object(${JSON.stringify(FAKE_PUBLIC_ENV)})`); + expect(Object.keys(env)).toEqual(['TINA_PUBLIC_ONLY']); }); - it('contains only the keys returned by filterPublicEnv', async () => { + it('sets server.fs.strict to true', async () => { const config = await createConfig({ configManager: stubConfigManager(), database: {} as Database, apiURL: 'http://localhost:4001/graphql', noWatch: true, }); - const raw = config.define!['process.env']; - const jsonStr = raw.replace(/^new Object\(/, '').replace(/\)$/, ''); - const env = JSON.parse(jsonStr); - expect(env).toEqual(FAKE_PUBLIC_ENV); + expect(config.server!.fs!.strict).toBe(true); }); - it('does not embed any values outside the filterPublicEnv result', async () => { - spy.mockReturnValue({ TINA_PUBLIC_ONLY: 'safe' }); + it('allows spaRootPath and rootPath in server.fs', async () => { + const cm = stubConfigManager(); + const config = await createConfig({ + configManager: cm, + database: {} as Database, + apiURL: 'http://localhost:4001/graphql', + noWatch: true, + }); + + expect(config.server!.fs!.allow).toContain(cm.spaRootPath); + expect(config.server!.fs!.allow).toContain(cm.rootPath); + }); + it('allows contentRootPath when it differs from rootPath', async () => { + const cm = stubConfigManager(); + (cm as any).contentRootPath = '/fake/content-repo'; + const config = await createConfig({ + configManager: cm, + database: {} as Database, + apiURL: 'http://localhost:4001/graphql', + noWatch: true, + }); + + expect(config.server!.fs!.allow).toContain('/fake/content-repo'); + }); + + it('does not duplicate rootPath when contentRootPath equals rootPath', async () => { + const cm = stubConfigManager(); + (cm as any).contentRootPath = cm.rootPath; + const config = await createConfig({ + configManager: cm, + database: {} as Database, + apiURL: 'http://localhost:4001/graphql', + noWatch: true, + }); + + const rootOccurrences = config.server!.fs!.allow!.filter( + (p) => p === cm.rootPath + ); + expect(rootOccurrences).toHaveLength(1); + }); + + it('sets server.cors.origin from buildCorsOriginCheck', async () => { const config = await createConfig({ configManager: stubConfigManager(), database: {} as Database, apiURL: 'http://localhost:4001/graphql', noWatch: true, }); - const raw = config.define!['process.env']; - const jsonStr = raw.replace(/^new Object\(/, '').replace(/\)$/, ''); - const env = JSON.parse(jsonStr); - expect(Object.keys(env)).toEqual(['TINA_PUBLIC_ONLY']); - expect(env.TINA_PUBLIC_ONLY).toBe('safe'); + expect(typeof (config.server!.cors as any).origin).toBe('function'); + }); + + it('passes server.allowedOrigins through to the CORS callback', async () => { + const cm = stubConfigManager(); + (cm.config as any).server = { + allowedOrigins: ['https://my-codespace.github.dev'], + }; + + const config = await createConfig({ + configManager: cm, + database: {} as Database, + apiURL: 'http://localhost:4001/graphql', + noWatch: true, + }); + + const originFn = (config.server!.cors as any).origin; + const allowed = await new Promise<boolean>((resolve) => { + originFn('https://my-codespace.github.dev', (_err: any, allow: boolean) => + resolve(!!allow) + ); + }); + expect(allowed).toBe(true); }); });
packages/@tinacms/cli/src/next/vite/index.ts+20 −1 modified@@ -10,6 +10,7 @@ import { splitVendorChunkPlugin, } from 'vite'; import type { ConfigManager } from '../config-manager'; +import { buildCorsOriginCheck } from './cors'; import { filterPublicEnv } from './filterPublicEnv'; import { tinaTailwind } from './tailwind'; @@ -202,6 +203,13 @@ export const createConfig = async ({ }, server: { host: configManager.config?.build?.host ?? false, + // Restrict Vite's built-in CORS to the same origins our custom + // middleware allows (localhost + user-configured allowedOrigins). + cors: { + origin: buildCorsOriginCheck( + configManager.config?.server?.allowedOrigins + ), + }, watch: noWatch ? { ignored: ['**/*'], @@ -213,7 +221,18 @@ export const createConfig = async ({ ], }, fs: { - strict: false, + strict: true, + // Allow serving files from the project root and the SPA package. + // Without this, Vite would block access to tina config/generated + // files since the Vite root is the @tinacms/app package directory. + allow: [ + configManager.spaRootPath, + configManager.rootPath, + ...(configManager.contentRootPath && + configManager.contentRootPath !== configManager.rootPath + ? [configManager.contentRootPath] + : []), + ], }, }, build: {
packages/@tinacms/cli/src/next/vite/plugins.ts+21 −11 modified@@ -1,21 +1,22 @@ -import AsyncLock from 'async-lock'; -import type { Plugin } from 'vite'; -import { createFilter, FilterPattern } from '@rollup/pluginutils'; -import type { Config } from '@svgr/core'; import fs from 'fs'; -import { transformWithEsbuild } from 'vite'; -import { transform as esbuildTransform } from 'esbuild'; import path from 'path'; -import bodyParser from 'body-parser'; -import cors from 'cors'; +import { FilterPattern, createFilter } from '@rollup/pluginutils'; +import type { Config } from '@svgr/core'; import { resolve as gqlResolve } from '@tinacms/graphql'; import type { Database } from '@tinacms/graphql'; +import AsyncLock from 'async-lock'; +import bodyParser from 'body-parser'; +import cors from 'cors'; +import { transform as esbuildTransform } from 'esbuild'; +import type { Plugin } from 'vite'; +import { transformWithEsbuild } from 'vite'; import { - parseMediaFolder, createMediaRouter, + parseMediaFolder, } from '../commands/dev-command/server/media'; -import type { ConfigManager } from '../config-manager'; import { createSearchIndexRouter } from '../commands/dev-command/server/searchIndex'; +import type { ConfigManager } from '../config-manager'; +import { buildCorsOriginCheck } from './cors'; export const transformTsxPlugin = ({ configManager: _configManager, @@ -59,10 +60,19 @@ export const devServerEndPointsPlugin = ({ searchIndex: any; databaseLock: (fn: () => Promise<void>) => Promise<void>; }) => { + const corsOriginCheck = buildCorsOriginCheck( + configManager.config?.server?.allowedOrigins + ); + const plug: Plugin = { name: 'graphql-endpoints', configureServer(server) { - server.middlewares.use(cors()); + server.middlewares.use( + cors({ + origin: corsOriginCheck, + methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'], + }) + ); server.middlewares.use(bodyParser.json({ limit: '5mb' })); server.middlewares.use(async (req, res, next: Function) => { const mediaPaths = configManager.config.media?.tina;
packages/@tinacms/cli/src/server/server.ts+11 −6 modified@@ -2,16 +2,17 @@ */ -import path from 'node:path'; -import cors from 'cors'; import http from 'node:http'; -import express from 'express'; +import path from 'node:path'; +import type { Database } from '@tinacms/graphql'; import { altairExpress } from 'altair-express-middleware'; // @ts-ignore import bodyParser from 'body-parser'; -import type { Database } from '@tinacms/graphql'; -import { createMediaRouter } from './routes'; +import cors from 'cors'; +import express from 'express'; +import { buildCorsOriginCheck } from '../next/vite/cors'; import { parseMediaFolder } from '../utils'; +import { createMediaRouter } from './routes'; export const gqlServer = async (database, verbose: boolean) => { // This is lazily required so we can update the module @@ -20,7 +21,11 @@ export const gqlServer = async (database, verbose: boolean) => { const app = express(); const server = http.createServer(app); - app.use(cors()); + app.use( + cors({ + origin: buildCorsOriginCheck(), + }) + ); app.use(bodyParser.json()); app.use(
packages/@tinacms/schema-tools/src/types/index.ts+23 −0 modified@@ -748,6 +748,29 @@ export interface Config< */ basePath?: string; }; + /** + * Configuration for the local development server (`tinacms dev`). + * Has no effect on production deployments. + */ + server?: { + /** + * Origins allowed to make cross-origin requests to the dev server. + * Defaults to localhost / 127.0.0.1 / [::1] only. Each entry can be a string, + * RegExp, or `'private'` (expands to RFC 1918 private-network IPs). + * + * @example + * ```ts + * server: { allowedOrigins: ['https://my-codespace.github.dev'] } + * ``` + * + * @example + * ```ts + * server: { allowedOrigins: ['private'] } + * ``` + * + */ + allowedOrigins?: (RegExp | 'private' | (string & {}))[]; + }; media?: | { /**
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-8pw3-9m7f-q734ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-28792ghsaADVISORY
- github.com/tinacms/tinacms/commit/56d533e610a520ba66b3e58f3a0dc03487d5d5d7ghsaWEB
- github.com/tinacms/tinacms/pull/6450ghsaWEB
- github.com/tinacms/tinacms/releases/tag/%40tinacms%2Fcli%402.1.8ghsaWEB
- github.com/tinacms/tinacms/security/advisories/GHSA-8pw3-9m7f-q734ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.